WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
package types
import (
"context"
"database/sql/driver"
"encoding/json"
"strings"
"time"
)
// DefaultMaxContextTokens is the context window assumed for a model that does
// not declare one. 200k matches typical windows on current chat/VLM models
// (GPT-4.1, Claude, Gemini, Qwen, etc.).
//
// It is still below the largest windows on the market. Guessing high is
// the dangerous direction: compaction never fires, and the first sign of
// trouble is the provider rejecting the request. Guessing low only costs some
// history that would have fit.
const DefaultMaxContextTokens = 200000
// AgentMaxContextTokens picks the context window for one agent run. An
// explicit agent setting wins, then whatever the model declares, then the
// assumed default.
func AgentMaxContextTokens(configured, modelContextWindow int) int {
if configured > 0 {
return configured
}
if modelContextWindow > 0 {
return modelContextWindow
}
return DefaultMaxContextTokens
}
// DefaultSmartReasoningMaxCompletionTokens is the per-round budget when the
// agent cannot emit write_sandbox_file / edit_sandbox_file. 4096 matches
// typical OpenAI-compatible provider defaults and is enough for ordinary
// tool-call JSON.
const DefaultSmartReasoningMaxCompletionTokens = 4096
// DefaultAgentMaxCompletionTokens is the per-round budget when the agent
// can write or edit sandbox files. Those tool calls carry the file body in
// JSON; a 4096 cap truncates mid-stream (finish_reason=length).
const DefaultAgentMaxCompletionTokens = 24576
// DefaultQuickAnswerMaxCompletionTokens is the RAG answer budget.
const DefaultQuickAnswerMaxCompletionTokens = 2048
// NeedsSandboxWriteCompletionBudget reports whether this agent may register
// write_sandbox_file / edit_sandbox_file. Those tools follow the bound
// sandbox, not the allowed_tools checklist.
func NeedsSandboxWriteCompletionBudget(sandboxConfigID string) bool {
return strings.TrimSpace(sandboxConfigID) != ""
}
// DefaultMaxCompletionTokens is the unset-config default for one agent:
// quick-answer 2048, smart-reasoning 4096, smart-reasoning with a sandbox 24576.
func DefaultMaxCompletionTokens(agentMode, sandboxConfigID string) int {
if agentMode == AgentModeSmartReasoning {
if NeedsSandboxWriteCompletionBudget(sandboxConfigID) {
return DefaultAgentMaxCompletionTokens
}
return DefaultSmartReasoningMaxCompletionTokens
}
return DefaultQuickAnswerMaxCompletionTokens
}
// AgentRoundMaxCompletionTokens returns the completion-token budget for one
// ReAct LLM round when no sandbox is bound. Zero (unset) uses
// DefaultSmartReasoningMaxCompletionTokens. An explicit configured value is
// honored as-is.
func AgentRoundMaxCompletionTokens(configured int) int {
return AgentRoundMaxCompletionTokensFor(configured, "")
}
// MinSandboxWriteCompletionTokens is the floor applied to an explicitly
// configured per-round budget when a sandbox is bound. write_sandbox_file and
// edit_sandbox_file carry the whole file body inside the tool-call JSON, so a
// cap sized for ordinary chat truncates the arguments mid-string on every
// attempt — the agent then burns its rounds rewriting a file it can never
// finish. Below this the setting is not a preference, it is a deadlock.
const MinSandboxWriteCompletionTokens = 8192
// AgentRoundMaxCompletionTokensFor is AgentRoundMaxCompletionTokens with
// the agent's sandbox: unset + sandbox uses the large write-file budget, and a
// configured value too small to hold a file write is raised to the floor.
func AgentRoundMaxCompletionTokensFor(configured int, sandboxConfigID string) int {
if configured > 0 {
if NeedsSandboxWriteCompletionBudget(sandboxConfigID) &&
configured < MinSandboxWriteCompletionTokens {
return MinSandboxWriteCompletionTokens
}
return configured
}
return DefaultMaxCompletionTokens(AgentModeSmartReasoning, sandboxConfigID)
}
// UnlimitedMaxIterations is the stored MaxIterations value meaning the ReAct
// loop has no round cap. Zero still means "unset, apply default" so agents
// that omit the field keep their current behaviour.
const UnlimitedMaxIterations = -1
// AgentConfig represents the full agent configuration (used at tenant level and runtime)
// This includes all configuration parameters for agent execution
type AgentConfig struct {
// MaxIterations caps ReAct rounds. Zero is unset (filled with a default);
// a negative value is unlimited — the loop runs until the model stops,
// the user cancels, or another guard fires. See UnlimitedMaxIterations.
MaxIterations int `json:"max_iterations"`
AllowedTools []string `json:"allowed_tools"` // List of allowed tool names
Temperature float64 `json:"temperature"` // LLM temperature for agent
KnowledgeBases []string `json:"knowledge_bases"` // Accessible knowledge base IDs
KnowledgeIDs []string `json:"knowledge_ids"` // Accessible knowledge IDs (individual documents)
SystemPrompt string `json:"system_prompt,omitempty"` // Unified system prompt (uses web_search_status placeholder for dynamic behavior)
InstructionSkillPrompt string `json:"-"` // Resolved runtime-only Markdown rules, appended to the base prompt.
// Deprecated: Use SystemPrompt instead. Kept for backward compatibility during migration.
SystemPromptWebEnabled string `json:"system_prompt_web_enabled,omitempty"` // Deprecated: Custom prompt when web search is enabled
SystemPromptWebDisabled string `json:"system_prompt_web_disabled,omitempty"` // Deprecated: Custom prompt when web search is disabled
UseCustomSystemPrompt bool `json:"use_custom_system_prompt"` // Whether to use custom system prompt instead of default
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search tool is enabled
WebSearchMaxResults int `json:"web_search_max_results"` // Maximum number of web search results (default: 5)
WebSearchProviderID string `json:"web_search_provider_id,omitempty"` // WebSearchProviderEntity ID (resolved from agent config)
MultiTurnEnabled bool `json:"multi_turn_enabled"` // Whether multi-turn conversation is enabled
HistoryTurns int `json:"history_turns"` // Number of history turns to keep in context
MemoryEnabled *bool `json:"memory_enabled,omitempty"` // nil inherits workspace
SearchTargets SearchTargets `json:"-"` // Pre-computed unified search targets (runtime only)
// MCP service selection
MCPSelectionMode string `json:"mcp_selection_mode"` // MCP selection mode: "all", "selected", "none"
MCPServices []string `json:"mcp_services"` // Selected MCP service IDs (when mode is "selected")
// MCPAuthWaitTimeout is how many seconds an agent run waits for
// in-conversation OAuth authorization before skipping. <=0 falls back to
// the gate's configured timeout. The wait is always bounded (no leak).
MCPAuthWaitTimeout int `json:"mcp_auth_wait_timeout,omitempty"`
// Whether to enable thinking mode (for models that support extended thinking)
Thinking *bool `json:"thinking"`
// Whether final answers include knowledge/web source citations. Nil defaults to true.
CitationEnabled *bool `json:"citation_enabled"`
// Whether to retrieve knowledge base only when explicitly mentioned with @ (default: false)
RetrieveKBOnlyWhenMentioned bool `json:"retrieve_kb_only_when_mentioned"`
// Whether to retain retrieval history (like wiki_read_page results) across turns (default: false)
RetainRetrievalHistory bool `json:"retain_retrieval_history"`
// Skills configuration (Progressive Disclosure pattern)
SkillsEnabled bool `json:"skills_enabled"` // Whether skills are enabled (default: false)
SkillDirs []string `json:"skill_dirs"` // Directories to search for skills
AllowedSkills []string `json:"allowed_skills"` // Skill names whitelist (empty = allow all)
// Runtime-only fields (not persisted)
VLMModelID string `json:"-"` // VLM model ID for tool result image analysis (set from CustomAgent config)
SandboxConfigID string `json:"-"` // Workspace sandbox config ID for skill execution (set from CustomAgent config)
// TenantSkills are the skills installed into the selected sandbox config's
// snapshot image, already narrowed to the ones this run can actually
// invoke. Runtime only: it is derived per turn from the config the agent
// selected, never stored on the agent record.
TenantSkills []*TenantSkillEntity `json:"-"`
// Per-request @mention pins (runtime only; injected as <must_use> in the user message).
PinnedMCPServiceIDs []string `json:"-"`
PinnedSkillNames []string `json:"-"`
// SharedAgentReadOnly prevents a shared agent from mutating resources in
// its source workspace. It is set from the verified share relation, never
// inferred from a client-provided tenant ID.
SharedAgentReadOnly bool `json:"-"`
// LLM call timeout in seconds (default: 120). Controls the maximum time for a single LLM call.
LLMCallTimeout int `json:"llm_call_timeout,omitempty"`
// Maximum completion tokens for each ReAct LLM round. Zero means
// DefaultMaxCompletionTokens for this agent's mode and sandbox.
// Explicit values are sent as-is.
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
// Maximum character length for tool output (default: 16000).
// Outputs exceeding this limit are truncated with head + tail preservation.
MaxToolOutputChars int `json:"max_tool_output_chars,omitempty"`
// Maximum context window tokens for the agent. Zero means "use the
// model's context_window, or DefaultMaxContextTokens (200000)".
MaxContextTokens int `json:"max_context_tokens,omitempty"`
// How much recent conversation a compaction keeps verbatim. Zero means
// compaction.DefaultKeepRecentTokens, scaled down on small windows. This
// is the knob that decides how often compaction runs: whatever the context
// grew to, it comes out of a compaction at roughly this size.
CompactionKeepRecentTokens int `json:"compaction_keep_recent_tokens,omitempty"`
// Whether to execute independent tool calls in parallel (default: false).
// When enabled and the LLM returns multiple tool calls, they run concurrently via errgroup.
ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"`
// skillInstallMode routes this run's shell_exec to the privileged
// install-mode executor (root, skills image root writable). It is
// unexported on purpose: JSON cannot reach it, so no stored agent record
// and no API payload can request the privilege, and no other package can
// assign it. EnableSkillInstallMode is the only way in and it refuses
// every agent except the built-in skill installer.
skillInstallMode bool
// skillInstallDir is the one skill directory this install owns. The skill
// file tools are scoped to it, so an installer cannot write a neighbouring
// skill in the shared image. Unexported for the same reason as
// skillInstallMode: the scope of a privilege must not be settable by
// anything a tenant can store or send.
skillInstallDir string
}
// EnableSkillInstallMode grants install-mode shell execution to the built-in
// skill installer agent and to nothing else, scoped to the skill directory it
// was started for. The agent ID is checked here rather than at the call site
// so there is exactly one place to audit.
//
// The privilege and its scope are granted together: a caller cannot obtain the
// root shell without naming the directory the file tools will be confined to.
// skillDir is stored verbatim and validated where the tools are constructed —
// types cannot import the sandbox package that owns the path rules.
func (c *AgentConfig) EnableSkillInstallMode(agentID, skillDir string) {
if c == nil || agentID != BuiltinSkillInstallerID {
return
}
c.skillInstallMode = true
c.skillInstallDir = skillDir
}
// SkillInstallMode reports whether this run may use the privileged
// install-mode shell.
func (c *AgentConfig) SkillInstallMode() bool {
return c != nil && c.skillInstallMode
}
// SkillInstallDir is the skill directory this install may write, or "" when
// this run is not an install.
func (c *AgentConfig) SkillInstallDir() string {
if c == nil || !c.skillInstallMode {
return ""
}
return c.skillInstallDir
}
// UnlimitedIterations reports whether the ReAct loop has no round cap.
func (c *AgentConfig) UnlimitedIterations() bool {
return c != nil && c.MaxIterations < 0
}
// CitationsEnabled preserves citation output for legacy runtime configs that
// predate the setting and therefore have a nil CitationEnabled value.
func (c *AgentConfig) CitationsEnabled() bool {
return c == nil || c.CitationEnabled == nil || *c.CitationEnabled
}
// SessionAgentConfig represents session-level agent configuration
// Sessions only store Enabled and KnowledgeBases; other configs are read from Tenant at runtime
type SessionAgentConfig struct {
AgentModeEnabled bool `json:"agent_mode_enabled"` // Whether agent mode is enabled for this session
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this session
KnowledgeBases []string `json:"knowledge_bases"` // Accessible knowledge base IDs for this session
KnowledgeIDs []string `json:"knowledge_ids"` // Accessible knowledge IDs (individual documents) for this session
}
// Value implements driver.Valuer interface for AgentConfig
func (c AgentConfig) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements sql.Scanner interface for AgentConfig
func (c *AgentConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
var b []byte
switch v := value.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return nil
}
return json.Unmarshal(b, c)
}
// Value implements driver.Valuer interface for SessionAgentConfig
func (c SessionAgentConfig) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements sql.Scanner interface for SessionAgentConfig
func (c *SessionAgentConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
var b []byte
switch v := value.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return nil
}
return json.Unmarshal(b, c)
}
// ResolveSystemPrompt returns the prompt template for the given web search state.
// It uses the unified SystemPrompt field, falling back to deprecated fields for backward compatibility.
func (c *AgentConfig) ResolveSystemPrompt(webSearchEnabled bool) string {
if c == nil {
return ""
}
// First, try the new unified SystemPrompt field
if c.SystemPrompt != "" {
return c.SystemPrompt
}
// Fallback to deprecated fields for backward compatibility
if webSearchEnabled {
if c.SystemPromptWebEnabled != "" {
return c.SystemPromptWebEnabled
}
} else {
if c.SystemPromptWebDisabled != "" {
return c.SystemPromptWebDisabled
}
}
return ""
}
// Tool defines the interface that all agent tools must implement
type Tool interface {
// Name returns the unique identifier for this tool
Name() string
// Description returns a human-readable description of what the tool does
Description() string
// Parameters returns the JSON Schema for the tool's parameters
Parameters() json.RawMessage
// Execute runs the tool with the given arguments
Execute(ctx context.Context, args json.RawMessage) (*ToolResult, error)
}
// Cleanable is an optional interface that tools can implement to release resources.
// Tools implementing this interface will have their Cleanup method called during
// registry cleanup (e.g., at the end of an agent session).
type Cleanable interface {
Cleanup(ctx context.Context)
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
Success bool `json:"success"` // Whether the tool executed successfully
Output string `json:"output"` // Human-readable output
Data map[string]interface{} `json:"data,omitempty"` // Structured data for programmatic use
Error string `json:"error,omitempty"` // Error message if execution failed
Images []string `json:"images,omitempty"` // Base64 data URIs from tool (e.g. MCP image content)
}
// ToolCall represents a single tool invocation within an agent step
type ToolCall struct {
ID string `json:"id"` // Function call ID from LLM
Name string `json:"name"` // Tool name
Args map[string]interface{} `json:"args"` // Tool arguments
Result *ToolResult `json:"result"` // Execution result (contains Output)
Reflection string `json:"reflection,omitempty"` // Agent's reflection on this tool call result (if enabled)
Duration int64 `json:"duration"` // Execution time in milliseconds
ProviderMetadata ToolCallMetadata `json:"provider_metadata,omitempty"` // Provider-specific tool-call state for replay
}
// PipelineToolCallIDPrefix marks a persisted tool call the model never made.
// The fast-answer (KnowledgeQA) pipeline records its retrieval stages as tool
// calls so a reloaded conversation can redraw the same timeline it showed while
// streaming. History replay must skip them: asking the model to account for
// calls it never issued, against tools it may not even have, breaks the
// request protocol.
const PipelineToolCallIDPrefix = "ragpipe-"
// IsPipelineToolCallID reports whether a tool call was synthesized by the
// fast-answer pipeline rather than requested by the model.
func IsPipelineToolCallID(id string) bool {
return strings.HasPrefix(id, PipelineToolCallIDPrefix)
}
// AgentStep represents one iteration of the ReAct loop
type AgentStep struct {
Iteration int `json:"iteration"` // Iteration number (0-indexed)
Thought string `json:"thought"` // LLM's reasoning/thinking (Think phase)
// ReasoningContent stores the OpenAI-protocol reasoning_content emitted by the
// model in this round. Persisted on AgentStep so cross-turn replay can put it
// back on the assistant message — required by MiMo / DeepSeek V3.2+ thinking
// mode, ignored by providers that don't recognize the field.
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls"` // Tools called in this step (Act phase)
Timestamp time.Time `json:"timestamp"` // When this step occurred
}
// GetObservations returns observations from all tool calls in this step
// This is a convenience method to maintain backward compatibility
func (s *AgentStep) GetObservations() []string {
observations := make([]string, 0, len(s.ToolCalls))
for _, tc := range s.ToolCalls {
if tc.Result != nil && tc.Result.Output != "" {
observations = append(observations, tc.Result.Output)
}
if tc.Reflection != "" {
observations = append(observations, "Reflection: "+tc.Reflection)
}
}
return observations
}
// AgentState tracks the execution state of an agent across iterations
type AgentState struct {
CurrentRound int `json:"current_round"` // Current round number
RoundSteps []AgentStep `json:"round_steps"` // All steps taken so far in the current round
IsComplete bool `json:"is_complete"` // Whether agent has finished
FinalAnswer string `json:"final_answer"` // The final answer to the query
KnowledgeRefs []*SearchResult `json:"knowledge_refs"` // Collected knowledge references
TurnUsage TokenUsage `json:"turn_usage"` // LLM token usage accumulated across every round of this turn
}
// FunctionDefinition represents a function definition for LLM function calling
type FunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
+40
View File
@@ -0,0 +1,40 @@
package types
import (
"fmt"
"strings"
"unicode/utf8"
)
const (
AgentRoleGeneral = "general"
AgentRoleCoordinator = "coordinator"
AgentRoleProduction = "production"
AgentRoleQC = "qc"
AgentRoleService = "service"
AgentRoleTrainingMap = "training_map"
)
func (c CustomAgentConfig) ValidateDuty() error {
switch c.Role {
case "", AgentRoleGeneral, AgentRoleCoordinator, AgentRoleProduction, AgentRoleQC, AgentRoleService, AgentRoleTrainingMap:
default:
return fmt.Errorf("Agent 职责无效")
}
if len(c.ContentTags) > 32 {
return fmt.Errorf("Agent 内容标签最多 32 个")
}
seen := make(map[string]bool, len(c.ContentTags))
for _, tag := range c.ContentTags {
if strings.TrimSpace(tag) == "" || utf8.RuneCountInString(tag) > 64 || strings.ContainsRune(tag, 0) || seen[tag] {
return fmt.Errorf("Agent 内容标签须为不重复的 1 到 64 字文本")
}
seen[tag] = true
}
return nil
}
// Older native agents had no duty; keep them usable until explicitly classified.
func (c CustomAgentConfig) SupportsDuty(role string) bool {
return c.Role == "" || c.Role == AgentRoleGeneral || c.Role == role
}
+30
View File
@@ -0,0 +1,30 @@
package types
import (
"strings"
"testing"
)
func TestAgentDutyValidationAndCompatibility(t *testing.T) {
for _, role := range []string{"", AgentRoleGeneral, AgentRoleCoordinator, AgentRoleProduction, AgentRoleQC, AgentRoleService, AgentRoleTrainingMap} {
c := CustomAgentConfig{Role: role, ContentTags: []string{"开场", "课程"}}
if err := c.ValidateDuty(); err != nil {
t.Fatal(err)
}
for _, duty := range []string{AgentRoleProduction, AgentRoleTrainingMap} {
want := role == "" || role == AgentRoleGeneral || role == duty
if c.SupportsDuty(duty) != want {
t.Fatalf("role %q duty %q", role, duty)
}
}
}
for _, c := range []CustomAgentConfig{
{Role: "typo"}, {ContentTags: []string{" "}}, {ContentTags: []string{"same", "same"}},
{ContentTags: []string{strings.Repeat("字", 65)}}, {ContentTags: []string{"null\x00tag"}},
{ContentTags: make([]string, 33)},
} {
if c.ValidateDuty() == nil {
t.Fatal("invalid duty config accepted")
}
}
}
+26
View File
@@ -0,0 +1,26 @@
package types
import (
"fmt"
"strings"
)
const MaxFusionAgents = 8
// Fusion is one level of expression input; it never expands retrieval scope.
func (c CustomAgentConfig) ValidateFusion(primaryID string) error {
if len(c.FusionAgentIDs) > MaxFusionAgents {
return fmt.Errorf("融合 Agent 最多选择 %d 个", MaxFusionAgents)
}
if len(c.FusionAgentIDs) > 0 && c.Role != AgentRoleProduction {
return fmt.Errorf("只有内容生产 Agent 可以配置融合 Agent")
}
seen := make(map[string]bool, len(c.FusionAgentIDs))
for _, id := range c.FusionAgentIDs {
if strings.TrimSpace(id) != id || id == "" || len(id) > 64 || strings.ContainsRune(id, 0) || id == primaryID || seen[id] {
return fmt.Errorf("融合 Agent 不可为空、重复或选择自身")
}
seen[id] = true
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package types
import (
"github.com/stretchr/testify/require"
"testing"
)
func TestFusionConfigValidation(t *testing.T) {
for _, ids := range [][]string{{"a", "a"}, {"self"}, {""}, {" a"}, {"1", "2", "3", "4", "5", "6", "7", "8", "9"}} {
require.Error(t, (CustomAgentConfig{Role: AgentRoleProduction, FusionAgentIDs: ids}).ValidateFusion("self"))
}
require.NoError(t, (CustomAgentConfig{Role: AgentRoleProduction, FusionAgentIDs: []string{"a", "b"}}).ValidateFusion("self"))
require.Error(t, (CustomAgentConfig{Role: AgentRoleQC, FusionAgentIDs: []string{"a"}}).ValidateFusion("self"))
}
+24
View File
@@ -0,0 +1,24 @@
package types
import (
"fmt"
"strconv"
"strings"
)
const AgentSourceTenantIDParam = "agent_source_tenant_id"
// ParseAgentSourceTenantID parses the optional shared-agent source workspace selector.
// Empty or whitespace input is treated as absent (0, nil). Non-empty but invalid
// values return an error so callers fail closed instead of silently falling back.
func ParseAgentSourceTenantID(raw string) (uint64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
value, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid %s: %w", AgentSourceTenantIDParam, err)
}
return value, nil
}
+31
View File
@@ -0,0 +1,31 @@
package types
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseAgentSourceTenantIDEmpty(t *testing.T) {
value, err := ParseAgentSourceTenantID("")
require.NoError(t, err)
require.Equal(t, uint64(0), value)
value, err = ParseAgentSourceTenantID(" ")
require.NoError(t, err)
require.Equal(t, uint64(0), value)
}
func TestParseAgentSourceTenantIDValid(t *testing.T) {
value, err := ParseAgentSourceTenantID("42")
require.NoError(t, err)
require.Equal(t, uint64(42), value)
}
func TestParseAgentSourceTenantIDInvalid(t *testing.T) {
_, err := ParseAgentSourceTenantID("abc")
require.Error(t, err)
_, err = ParseAgentSourceTenantID("42abc")
require.Error(t, err)
}
+126
View File
@@ -0,0 +1,126 @@
package types
import (
"testing"
)
func TestResolveSystemPrompt(t *testing.T) {
tests := []struct {
name string
config *AgentConfig
webSearchEnabled bool
want string
}{
{
name: "nil config returns empty",
config: nil,
webSearchEnabled: false,
want: "",
},
{
name: "unified SystemPrompt returned",
config: &AgentConfig{SystemPrompt: "my prompt"},
webSearchEnabled: false,
want: "my prompt",
},
{
name: "deprecated web-disabled fallback",
config: &AgentConfig{
SystemPromptWebDisabled: "no-web",
SystemPromptWebEnabled: "with-web",
},
webSearchEnabled: false,
want: "no-web",
},
{
name: "deprecated web-enabled fallback",
config: &AgentConfig{
SystemPromptWebDisabled: "no-web",
SystemPromptWebEnabled: "with-web",
},
webSearchEnabled: true,
want: "with-web",
},
{
name: "empty config returns empty",
config: &AgentConfig{},
webSearchEnabled: false,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.ResolveSystemPrompt(tt.webSearchEnabled)
if got != tt.want {
t.Errorf("ResolveSystemPrompt() = %q, want %q", got, tt.want)
}
})
}
}
// TestUseCustomSystemPromptFlag verifies that UseCustomSystemPrompt=false
// does not block a non-empty SystemPrompt from being resolved by agent_service.
// The fix in agent_service.go checks config.SystemPrompt != "" as a fallback,
// so ResolveSystemPrompt must return the prompt even when the flag is false.
func TestUseCustomSystemPromptFlag(t *testing.T) {
cfg := &AgentConfig{
SystemPrompt: "custom prompt",
UseCustomSystemPrompt: false, // flag NOT set
}
// ResolveSystemPrompt should still return the prompt
got := cfg.ResolveSystemPrompt(false)
if got != "custom prompt" {
t.Errorf("expected 'custom prompt', got %q", got)
}
}
// The window the agent plans against must come from the model when the model
// knows it. Assuming a window wider than the real one means compaction never
// runs and the provider rejects the request instead.
func TestAgentMaxContextTokens(t *testing.T) {
if got := AgentMaxContextTokens(0, 0); got != DefaultMaxContextTokens {
t.Fatalf("nothing known = %d, want %d", got, DefaultMaxContextTokens)
}
if got := AgentMaxContextTokens(0, 32768); got != 32768 {
t.Fatalf("model window unused: got %d, want 32768", got)
}
if got := AgentMaxContextTokens(64000, 32768); got != 64000 {
t.Fatalf("explicit agent setting must win, got %d", got)
}
if got := AgentMaxContextTokens(64000, 0); got != 64000 {
t.Fatalf("explicit agent setting with unknown model = %d, want 64000", got)
}
}
func TestAgentRoundMaxCompletionTokens(t *testing.T) {
if got := AgentRoundMaxCompletionTokens(0); got != DefaultSmartReasoningMaxCompletionTokens {
t.Fatalf("zero configured = %d, want %d", got, DefaultSmartReasoningMaxCompletionTokens)
}
if got := AgentRoundMaxCompletionTokensFor(0, "cfg-a"); got != DefaultAgentMaxCompletionTokens {
t.Fatalf("unset with sandbox = %d, want %d", got, DefaultAgentMaxCompletionTokens)
}
if got := AgentRoundMaxCompletionTokens(2048); got != 2048 {
t.Fatalf("explicit 2048 = %d, want 2048", got)
}
if got := AgentRoundMaxCompletionTokens(4096); got != 4096 {
t.Fatalf("explicit 4096 = %d, want 4096", got)
}
// With a sandbox bound, a cap this small cannot hold a file body inside the
// tool-call JSON, so every write truncates mid-string and the agent can
// never finish. The floor makes the setting survivable rather than a
// deadlock.
if got := AgentRoundMaxCompletionTokensFor(4096, "cfg-a"); got != MinSandboxWriteCompletionTokens {
t.Fatalf("explicit 4096 with sandbox = %d, want floor %d", got, MinSandboxWriteCompletionTokens)
}
if got := AgentRoundMaxCompletionTokensFor(32768, "cfg-a"); got != 32768 {
t.Fatalf("explicit value above the floor must be honored, got %d", got)
}
if got := AgentRoundMaxCompletionTokens(4096); got != 4096 {
t.Fatalf("no sandbox means no floor, got %d", got)
}
if got := AgentRoundMaxCompletionTokens(64000); got != 64000 {
t.Fatalf("explicit high value = %d, want 64000", got)
}
}
+213
View File
@@ -0,0 +1,213 @@
package types
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"gopkg.in/yaml.v3"
)
// ---------------------------------------------------------------------------
// YAML data structures for config/agent_type_presets.yaml
// ---------------------------------------------------------------------------
// AgentTypePresetI18n holds localised label and description for a single locale.
type AgentTypePresetI18n struct {
Label string `yaml:"label" json:"label"`
Description string `yaml:"description" json:"description"`
}
// AgentTypeKBFilter describes how the agent editor should filter selectable KBs
// when this preset is active. Predicates are evaluated against the KB's
// capabilities (see KBCapabilities).
//
// If all three slices are empty, the preset imposes no KB restriction.
type AgentTypeKBFilter struct {
// AnyOf: KB must expose at least ONE of these capability flags.
AnyOf []string `yaml:"any_of" json:"any_of,omitempty"`
// AllOf: KB must expose ALL of these capability flags.
AllOf []string `yaml:"all_of" json:"all_of,omitempty"`
// NoneOf: KB must expose NONE of these capability flags.
NoneOf []string `yaml:"none_of" json:"none_of,omitempty"`
}
// AgentTypePresetEntry is one entry in the agent_type_presets list in YAML.
//
// Only fields relevant for auto-filling the editor form are included under
// `config`. Everything else (retrieval thresholds, etc.) falls back to the
// user-edited defaults / CustomAgent.EnsureDefaults.
type AgentTypePresetEntry struct {
ID string `yaml:"id" json:"id"`
I18n map[string]AgentTypePresetI18n `yaml:"i18n" json:"i18n"`
Config *AgentTypePresetConfig `yaml:"config" json:"config,omitempty"`
KBFilter *AgentTypeKBFilter `yaml:"kb_filter" json:"kb_filter,omitempty"`
}
// AgentTypePresetConfig is a restricted view of CustomAgentConfig used purely
// as the preset payload. Any field left at its zero value is NOT applied by
// the preset so the user's existing value stays untouched.
//
// We mirror json tags (not yaml tags) directly from CustomAgentConfig so the
// frontend can apply them via simple Object.assign.
type AgentTypePresetConfig struct {
SystemPromptID string `yaml:"system_prompt_id" json:"system_prompt_id,omitempty"`
Temperature float64 `yaml:"temperature" json:"temperature,omitempty"`
MaxIterations int `yaml:"max_iterations" json:"max_iterations,omitempty"`
AllowedTools []string `yaml:"allowed_tools" json:"allowed_tools,omitempty"`
RetainRetrievalHistory bool `yaml:"retain_retrieval_history" json:"retain_retrieval_history,omitempty"`
FAQPriorityEnabled bool `yaml:"faq_priority_enabled" json:"faq_priority_enabled,omitempty"`
WebSearchEnabled bool `yaml:"web_search_enabled" json:"web_search_enabled,omitempty"`
SupportedFileTypes []string `yaml:"supported_file_types" json:"supported_file_types,omitempty"`
// KBSelectionMode presets the KB picker mode: "all" | "selected" | "none".
// When empty, the user's current value stays untouched.
KBSelectionMode string `yaml:"kb_selection_mode" json:"kb_selection_mode,omitempty"`
}
// agentTypePresetsFile is the top-level YAML structure.
type agentTypePresetsFile struct {
Presets []AgentTypePresetEntry `yaml:"agent_type_presets"`
}
// ---------------------------------------------------------------------------
// Global registry (populated from YAML at startup)
// ---------------------------------------------------------------------------
var (
agentTypePresets map[string]*AgentTypePresetEntry // keyed by preset ID
agentTypePresetsMu sync.RWMutex
agentTypePresetsOnce sync.Once
agentTypePresetIDs []string // insertion order, for stable UI listing
)
// LoadAgentTypePresetsConfig loads agent-type preset definitions from the given
// config directory (e.g. "./config"). The file must be named
// "agent_type_presets.yaml". Called once at startup (after LoadConfig determines
// the config directory).
//
// If the file does not exist, this is a no-op and the registry stays empty —
// in that case the frontend will simply not offer the agent-type dropdown.
func LoadAgentTypePresetsConfig(configDir string) error {
var loadErr error
agentTypePresetsOnce.Do(func() {
filePath := filepath.Join(configDir, "agent_type_presets.yaml")
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return
}
loadErr = fmt.Errorf("read agent_type_presets.yaml: %w", err)
return
}
var file agentTypePresetsFile
if err := yaml.Unmarshal(data, &file); err != nil {
loadErr = fmt.Errorf("parse agent_type_presets.yaml: %w", err)
return
}
agentTypePresetsMu.Lock()
defer agentTypePresetsMu.Unlock()
agentTypePresets = make(map[string]*AgentTypePresetEntry, len(file.Presets))
agentTypePresetIDs = make([]string, 0, len(file.Presets))
for i := range file.Presets {
entry := &file.Presets[i]
if entry.ID == "" {
continue
}
agentTypePresets[entry.ID] = entry
agentTypePresetIDs = append(agentTypePresetIDs, entry.ID)
}
})
return loadErr
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
// ListAgentTypePresetsWithContext returns all registered agent-type presets in
// their YAML-declared order, with i18n resolved against the request locale.
//
// The returned slice is a fresh copy safe to mutate by the caller (e.g. to
// tweak the i18n label before encoding).
func ListAgentTypePresetsWithContext(ctx context.Context) []AgentTypePresetEntry {
locale := localeFromCtx(ctx)
agentTypePresetsMu.RLock()
defer agentTypePresetsMu.RUnlock()
out := make([]AgentTypePresetEntry, 0, len(agentTypePresetIDs))
for _, id := range agentTypePresetIDs {
entry := agentTypePresets[id]
if entry == nil {
continue
}
// Shallow copy and narrow i18n down to the caller's locale so the
// API response is compact. We keep both "default" and the resolved
// locale entry in case the frontend wants to fall back.
cp := *entry
if len(entry.I18n) > 0 {
cp.I18n = resolveAgentTypeI18n(entry.I18n, locale)
}
out = append(out, cp)
}
return out
}
// GetAgentTypePreset returns the raw entry for the given preset ID, or nil.
// Used by prompt-resolution at startup.
func GetAgentTypePreset(id string) *AgentTypePresetEntry {
agentTypePresetsMu.RLock()
defer agentTypePresetsMu.RUnlock()
return agentTypePresets[id]
}
// ResolveAgentTypePresetPromptRefs iterates over all presets and resolves
// system_prompt_id references into the actual prompt template content via the
// provided resolver. This lets downstream services treat the `system_prompt_id`
// field as a concrete prompt string if they need to.
//
// We keep system_prompt_id as-is in the preset (it's also what the editor
// writes back to CustomAgentConfig), so this hook is only used to validate
// that every referenced template actually exists at startup.
func ResolveAgentTypePresetPromptRefs(resolver func(id string) string) {
agentTypePresetsMu.Lock()
defer agentTypePresetsMu.Unlock()
for _, entry := range agentTypePresets {
if entry == nil || entry.Config == nil || entry.Config.SystemPromptID == "" {
continue
}
if content := resolver(entry.Config.SystemPromptID); content == "" {
fmt.Printf(
"Warning: agent type preset %q references system_prompt_id %q but template not found\n",
entry.ID, entry.Config.SystemPromptID,
)
}
}
}
// resolveAgentTypeI18n picks a reasonable subset of i18n entries for the given
// locale: the exact-match entry (if any) plus the "default" fallback. Callers
// that want full i18n can pass an empty locale to get everything.
func resolveAgentTypeI18n(m map[string]AgentTypePresetI18n, locale string) map[string]AgentTypePresetI18n {
out := make(map[string]AgentTypePresetI18n, 2)
if v, ok := m["default"]; ok {
out["default"] = v
}
if locale != "" {
if v, ok := m[locale]; ok {
out[locale] = v
}
}
// If neither matched, surface the whole map so the frontend can decide.
if len(out) == 0 {
for k, v := range m {
out[k] = v
}
}
return out
}
+237
View File
@@ -0,0 +1,237 @@
package types
import (
"time"
)
// AuditAction names a single audited action class. Action constants are
// dot-namespaced (`<area>.<event>`) so future PRs can plug in their own
// areas (e.g. `kb.shared`, `agent.copied`) without colliding with the
// RBAC events PR 6 ships.
type AuditAction string
const (
// AuditActionMemberAdded fires when a tenant Owner / Admin adds a
// new tenant_members row. The actor is the inviter; the target is
// the invited user.
AuditActionMemberAdded AuditAction = "rbac.member_added"
// AuditActionMemberRemoved fires when an Owner / Admin removes a
// tenant_members row. Distinct from MemberLeft so an audit reader
// can tell "kicked out" from "left voluntarily".
AuditActionMemberRemoved AuditAction = "rbac.member_removed"
// AuditActionMemberRoleChanged fires for promote/demote operations.
// The Details payload carries old_role and new_role.
AuditActionMemberRoleChanged AuditAction = "rbac.member_role_changed"
// AuditActionMemberLeft fires on POST /tenants/:id/leave — the
// actor and target are the same user.
AuditActionMemberLeft AuditAction = "rbac.member_left"
// AuditActionAccessDenied fires when middleware/rbac.go's
// RequireRole or RequireOwnershipOrRole rejects a request under
// EnableRBAC=true. Subject to 1-minute sliding-window dedup so a
// probing client cannot fill the table.
AuditActionAccessDenied AuditAction = "rbac.access_denied"
// AuditActionInvitationSent fires when an Owner issues a new
// tenant invitation. Actor is the inviter, TargetUserID is the
// invitee. Note: AuditActionMemberAdded only fires when the
// invitee actually accepts and the tenant_members row is created.
AuditActionInvitationSent AuditAction = "rbac.invitation_sent"
// AuditActionInvitationAccepted fires when the invitee accepts a
// pending invitation. Actor is the invitee (acting on their own
// inbox); the matching rbac.member_added is emitted in the same
// transaction.
AuditActionInvitationAccepted AuditAction = "rbac.invitation_accepted"
// AuditActionInvitationDeclined fires when the invitee rejects a
// pending invitation. Actor and target are the same user.
AuditActionInvitationDeclined AuditAction = "rbac.invitation_declined"
// AuditActionInvitationRevoked fires when a tenant Owner cancels
// a still-pending invitation before the invitee acts. Actor is
// the Owner; target is the invitee.
AuditActionInvitationRevoked AuditAction = "rbac.invitation_revoked"
// AuditActionInvitationExpired fires when the lazy sweep transitions
// an overdue pending row to expired. Actor is empty (system).
AuditActionInvitationExpired AuditAction = "rbac.invitation_expired"
// VectorStore lifecycle actions. Emitted by VectorStoreService.
// Cover both env-store-derived (__env_*) and DB store create /
// update / delete paths. Details payload identifies the store_id
// and the changed key set; secret values (Password, APIKey,
// connection_config encrypted blob) MUST NOT appear in details.
// AuditActionVectorStoreCreated fires when a new VectorStore row
// is committed to the DB (Phase 1 CRUD path). Actor is the tenant
// user; resource is the VectorStore.
AuditActionVectorStoreCreated AuditAction = "vector_store.created"
// AuditActionVectorStoreUpdated fires on UPDATE of any VectorStore
// mutable field. Details payload carries the changed key set
// (never the secret values themselves — only the field names).
AuditActionVectorStoreUpdated AuditAction = "vector_store.updated"
// AuditActionVectorStoreDeleted fires when a VectorStore is
// (soft-)deleted. Phase 2's delete guard already prevents deletion
// of stores with bound KBs; the audit row records the actor and
// the store_id for forensic traceability.
AuditActionVectorStoreDeleted AuditAction = "vector_store.deleted"
// OpenSearch-specific actions emitted by the driver shipped in
// Phase 3. The OpenSearch index is a derived resource of the
// VectorStore — these events capture cluster-side side effects
// (PUT /<index>, DELETE /<index>, POST /_reindex) that operators
// may need to correlate with VectorStore lifecycle events.
// AuditActionOpenSearchIndexCreated fires when the OpenSearch
// driver lazily creates a per-dimension index (the first time a
// KB with a given embedding dim binds to the store). Details
// payload: index name, alias name, dimension.
AuditActionOpenSearchIndexCreated AuditAction = "opensearch.index_created"
// AuditActionOpenSearchIndexDeleted fires when the OpenSearch
// driver drops an index (e.g. cascade from VectorStore delete).
AuditActionOpenSearchIndexDeleted AuditAction = "opensearch.index_deleted"
// AuditActionOpenSearchReindexExecuted fires when CopyIndices
// initiates a _reindex (sync or async). Details payload: source
// KB id, target KB id, sync-or-async, doc count if known.
AuditActionOpenSearchReindexExecuted AuditAction = "opensearch.reindex_executed"
// AuditActionSystemSettingChanged fires when a SystemAdmin updates
// a row in the platform-wide system_settings table via
// PUT /api/v1/system/admin/settings/:key. Details payload carries
// {key, value_type, old_value, new_value} — sensitive values are
// redacted server-side before logging when is_secret=true (P3+;
// for now no setting is marked secret). Audit rows always have
// tenant_id=0 because the change is system-scope, not workspace-scoped.
AuditActionSystemSettingChanged AuditAction = "system.setting_changed"
// AuditActionSystemAdminPromoted fires when a SystemAdmin grants
// system-administrator privileges to another user via
// POST /api/v1/system/admin/promote. ActorUserID is the promoter,
// TargetUserID is the user being promoted. Details payload carries
// {target_email, target_username, idempotent} — `idempotent=true`
// means the user was already a system admin and no row was written
// (we still emit the row so probing the endpoint leaves a trail).
// TenantID=0 because the change is system-scope.
AuditActionSystemAdminPromoted AuditAction = "system.admin_promoted"
// AuditActionSystemAdminRevoked fires when a SystemAdmin removes
// system-administrator privileges from another user via
// POST /api/v1/system/admin/revoke. Details payload carries
// {target_email, target_username, changed} — `changed=false` covers
// the idempotent path (target was already not an admin) so an audit
// reader can distinguish a real revoke from a noop attempt.
// TenantID=0 because the change is system-scope.
AuditActionSystemAdminRevoked AuditAction = "system.admin_revoked"
// AuditActionSystemUserPasswordReset fires when a SystemAdmin replaces
// another user's local password. Details identify the target and record
// session revocation, but never contain the old or new password.
AuditActionSystemUserPasswordReset AuditAction = "system.user_password_reset"
// AuditActionSystemUserCreated fires when a SystemAdmin provisions a
// new local user via POST /api/v1/system/admin/users/create. Details
// carry {target_email, target_username, password_generated, idempotent}
// password_generated=true only on the create path, idempotent=true
// marks a no-op hit on an already-existing identity.
// TenantID=0 (system-scope).
AuditActionSystemUserCreated AuditAction = "system.user_created"
AuditActionSystemAPIKeyCreated AuditAction = "system.api_key_created"
AuditActionSystemAPIKeyRevoked AuditAction = "system.api_key_revoked"
// Runtime queue mutations are privileged SystemAdmin actions. Retrying an
// archived task can repeat its original side effects; deleting one removes
// the Redis failure record. Both must leave a platform audit trail.
AuditActionSystemQueueTaskRetried AuditAction = "system.queue_task_retried"
AuditActionSystemQueueTaskDeleted AuditAction = "system.queue_task_deleted"
AuditActionSystemQueueTaskRunNow AuditAction = "system.queue_task_run_now"
AuditActionSystemQueueTaskCancelled AuditAction = "system.queue_task_cancelled"
// AuditActionSystemQueueArchivedPurged fires when an operator clears every
// archived (finally-failed) task in one queue in a single action. The
// detail payload records the queue and how many records were removed.
AuditActionSystemQueueArchivedPurged AuditAction = "system.queue_archived_purged"
// Knowledge-base activity actions. These rows use scope_type=knowledge_base
// and scope_id=<kb id>; TargetType/TargetID identify the concrete child
// resource when the operation is about a document, tag, data source, or share.
AuditActionKBCreated AuditAction = "kb.created"
AuditActionKBUpdated AuditAction = "kb.updated"
AuditActionKBDeleted AuditAction = "kb.deleted"
AuditActionKBDuplicated AuditAction = "kb.duplicated"
AuditActionKBCloneStarted AuditAction = "kb.clone_started"
AuditActionKBCloneCompleted AuditAction = "kb.clone_completed"
AuditActionKBCloneFailed AuditAction = "kb.clone_failed"
AuditActionKnowledgeCreated AuditAction = "knowledge.created"
AuditActionKnowledgeUpdated AuditAction = "knowledge.updated"
AuditActionKnowledgeDeleted AuditAction = "knowledge.deleted"
AuditActionKnowledgeBatchDeleted AuditAction = "knowledge.batch_deleted"
AuditActionKnowledgeReparseStarted AuditAction = "knowledge.reparse_started"
AuditActionKnowledgeParseCanceled AuditAction = "knowledge.parse_canceled"
AuditActionKnowledgeMoveStarted AuditAction = "knowledge.move_started"
AuditActionKnowledgeMoveCompleted AuditAction = "knowledge.move_completed"
AuditActionKnowledgeMoveFailed AuditAction = "knowledge.move_failed"
AuditActionTagCreated AuditAction = "tag.created"
AuditActionTagUpdated AuditAction = "tag.updated"
AuditActionTagDeleted AuditAction = "tag.deleted"
AuditActionDataSourceCreated AuditAction = "datasource.created"
AuditActionDataSourceUpdated AuditAction = "datasource.updated"
AuditActionDataSourceDeleted AuditAction = "datasource.deleted"
AuditActionDataSourceSyncStarted AuditAction = "datasource.sync_started"
AuditActionDataSourceSyncCompleted AuditAction = "datasource.sync_completed"
AuditActionDataSourceSyncFailed AuditAction = "datasource.sync_failed"
AuditActionDataSourcePaused AuditAction = "datasource.paused"
AuditActionDataSourceResumed AuditAction = "datasource.resumed"
AuditActionKBShareAdded AuditAction = "kb.share_added"
AuditActionKBSharePermissionChanged AuditAction = "kb.share_permission_changed"
AuditActionKBShareRemoved AuditAction = "kb.share_removed"
AuditActionWikiContentChanged AuditAction = "wiki.content_changed"
AuditActionFAQImportStarted AuditAction = "faq.import_started"
AuditActionFAQImportCompleted AuditAction = "faq.import_completed"
AuditActionFAQImportFailed AuditAction = "faq.import_failed"
)
// AuditOutcome separates asynchronous acceptance from terminal business
// results and middleware-level rejections. The UI can therefore avoid
// presenting a queued operation as already completed.
type AuditOutcome string
const (
AuditOutcomeSuccess AuditOutcome = "success"
// Accepted means the durable request was created and asynchronous work was
// submitted, but the background operation has not reached a terminal state.
AuditOutcomeAccepted AuditOutcome = "accepted"
AuditOutcomeDenied AuditOutcome = "denied"
AuditOutcomeFailed AuditOutcome = "failed"
AuditOutcomePartial AuditOutcome = "partial"
AuditOutcomeCanceled AuditOutcome = "canceled"
)
// AuditLog is a single immutable audit event. The schema is intentionally
// generic: PR 6 wires only RBAC events, but TargetType / TargetID /
// Details are set up to absorb KB / agent / datasource events in
// follow-up PRs without another migration. Knowledge-base task events use a
// bounded convention inside Details: task_id, trigger, processing_status,
// source/target ids, aggregate counts, and operation-specific mode/attempt.
//
// Rows are append-only — no UpdatedAt, no soft-delete column. The
// monotonic ID acts as both primary key and pagination cursor (newest-
// first is `WHERE id < AfterID ORDER BY id DESC`).
type AuditLog struct {
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement;index:idx_audit_logs_tenant_scope_desc,priority:4,sort:desc"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index:idx_audit_logs_tenant_id_desc,priority:1;index:idx_audit_logs_tenant_action,priority:1;index:idx_audit_logs_tenant_scope_desc,priority:1"`
ActorUserID string `json:"actor_user_id" gorm:"type:varchar(36);default:'';index:idx_audit_logs_actor"`
ActorRole string `json:"actor_role" gorm:"type:varchar(32);default:''"`
Action AuditAction `json:"action" gorm:"type:varchar(64);not null;index:idx_audit_logs_tenant_action,priority:2"`
ScopeType string `json:"scope_type" gorm:"type:varchar(32);default:'';index:idx_audit_logs_tenant_scope_desc,priority:2"`
ScopeID string `json:"scope_id" gorm:"type:varchar(64);default:'';index:idx_audit_logs_tenant_scope_desc,priority:3"`
TargetType string `json:"target_type" gorm:"type:varchar(32);default:''"`
TargetID string `json:"target_id" gorm:"type:varchar(64);default:''"`
TargetUserID string `json:"target_user_id" gorm:"type:varchar(36);default:''"`
RequestPath string `json:"request_path" gorm:"type:varchar(512);default:''"`
RequestMethod string `json:"request_method" gorm:"type:varchar(16);default:''"`
Outcome AuditOutcome `json:"outcome" gorm:"type:varchar(16);default:success"`
Details JSON `json:"details" gorm:"type:jsonb;default:'{}'"`
CreatedAt time.Time `json:"created_at" gorm:"index:idx_audit_logs_tenant_id_desc,priority:2,sort:desc"`
}
// TableName pins the table name even if a future GORM convention
// pluralisation refactor would otherwise rename it.
func (AuditLog) TableName() string { return "audit_logs" }
+207
View File
@@ -0,0 +1,207 @@
package types
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
// TestAuditAction_DotNamespaceConvention ensures every AuditAction
// constant follows the dot-namespaced `<area>.<event>` convention
// documented at the top of audit_log.go. Future PRs that add actions
// must keep this invariant so audit-log consumers can prefix-filter
// by area.
func TestAuditAction_DotNamespaceConvention(t *testing.T) {
all := []AuditAction{
// RBAC namespace (Phase 2 PR 5 / #1427)
AuditActionMemberAdded,
AuditActionMemberRemoved,
AuditActionMemberRoleChanged,
AuditActionMemberLeft,
AuditActionAccessDenied,
AuditActionInvitationSent,
AuditActionInvitationAccepted,
AuditActionInvitationDeclined,
AuditActionInvitationRevoked,
AuditActionInvitationExpired,
// VectorStore namespace (Phase 3 PR 1 / #1440)
AuditActionVectorStoreCreated,
AuditActionVectorStoreUpdated,
AuditActionVectorStoreDeleted,
// OpenSearch namespace (Phase 3 PR 1 / #1440)
AuditActionOpenSearchIndexCreated,
AuditActionOpenSearchIndexDeleted,
AuditActionOpenSearchReindexExecuted,
// System namespace (this PR — system admin & settings)
AuditActionSystemSettingChanged,
AuditActionSystemAdminPromoted,
AuditActionSystemAdminRevoked,
AuditActionSystemUserPasswordReset,
AuditActionSystemQueueTaskRetried,
AuditActionSystemQueueTaskDeleted,
AuditActionSystemQueueTaskRunNow,
AuditActionSystemQueueTaskCancelled,
AuditActionSystemQueueArchivedPurged,
}
for _, a := range all {
s := string(a)
area, event, ok := strings.Cut(s, ".")
assert.True(t, ok,
"action %q must contain exactly one dot separator", s)
assert.NotEmpty(t, area, "action %q has empty area", s)
assert.NotEmpty(t, event, "action %q has empty event", s)
}
}
// TestAuditAction_VectorStoreNamespacePrefix pins the three
// vector_store.* actions added in Phase 3 PR 1 to their shared area
// prefix. The prefix is the contract by which an audit-log reader
// can subscribe to "all VectorStore lifecycle events" with a single
// filter.
func TestAuditAction_VectorStoreNamespacePrefix(t *testing.T) {
cases := []AuditAction{
AuditActionVectorStoreCreated,
AuditActionVectorStoreUpdated,
AuditActionVectorStoreDeleted,
}
for _, a := range cases {
assert.True(t,
strings.HasPrefix(string(a), "vector_store."),
"expected %q to start with 'vector_store.'", a,
)
}
}
// TestAuditAction_OpenSearchNamespacePrefix pins the three
// opensearch.* actions added in Phase 3 PR 1 to their shared area
// prefix. OpenSearch index lifecycle is a driver-specific concern
// distinct from VectorStore lifecycle — both can co-occur for the
// same logical operation (e.g. CreateVectorStore + IndexCreated).
func TestAuditAction_OpenSearchNamespacePrefix(t *testing.T) {
cases := []AuditAction{
AuditActionOpenSearchIndexCreated,
AuditActionOpenSearchIndexDeleted,
AuditActionOpenSearchReindexExecuted,
}
for _, a := range cases {
assert.True(t,
strings.HasPrefix(string(a), "opensearch."),
"expected %q to start with 'opensearch.'", a,
)
}
}
// TestAuditAction_NoCollisionsAcrossNamespaces ensures no two
// AuditAction constants share the same wire string. A duplicate would
// silently merge two logical events into one entry in the audit-log
// UI, defeating the dot-namespace convention.
func TestAuditAction_NoCollisionsAcrossNamespaces(t *testing.T) {
seen := make(map[AuditAction]string)
register := func(name string, a AuditAction) {
t.Helper()
if prev, exists := seen[a]; exists {
t.Fatalf("collision: %s and %s both map to %q", prev, name, a)
}
seen[a] = name
}
register("AuditActionMemberAdded", AuditActionMemberAdded)
register("AuditActionMemberRemoved", AuditActionMemberRemoved)
register("AuditActionMemberRoleChanged", AuditActionMemberRoleChanged)
register("AuditActionMemberLeft", AuditActionMemberLeft)
register("AuditActionAccessDenied", AuditActionAccessDenied)
register("AuditActionInvitationSent", AuditActionInvitationSent)
register("AuditActionInvitationAccepted", AuditActionInvitationAccepted)
register("AuditActionInvitationDeclined", AuditActionInvitationDeclined)
register("AuditActionInvitationRevoked", AuditActionInvitationRevoked)
register("AuditActionInvitationExpired", AuditActionInvitationExpired)
register("AuditActionVectorStoreCreated", AuditActionVectorStoreCreated)
register("AuditActionVectorStoreUpdated", AuditActionVectorStoreUpdated)
register("AuditActionVectorStoreDeleted", AuditActionVectorStoreDeleted)
register("AuditActionOpenSearchIndexCreated", AuditActionOpenSearchIndexCreated)
register("AuditActionOpenSearchIndexDeleted", AuditActionOpenSearchIndexDeleted)
register("AuditActionOpenSearchReindexExecuted", AuditActionOpenSearchReindexExecuted)
register("AuditActionSystemSettingChanged", AuditActionSystemSettingChanged)
register("AuditActionSystemAdminPromoted", AuditActionSystemAdminPromoted)
register("AuditActionSystemAdminRevoked", AuditActionSystemAdminRevoked)
register("AuditActionSystemUserPasswordReset", AuditActionSystemUserPasswordReset)
register("AuditActionSystemUserCreated", AuditActionSystemUserCreated)
register("AuditActionSystemQueueTaskRetried", AuditActionSystemQueueTaskRetried)
register("AuditActionSystemQueueTaskDeleted", AuditActionSystemQueueTaskDeleted)
register("AuditActionSystemQueueTaskRunNow", AuditActionSystemQueueTaskRunNow)
register("AuditActionSystemQueueTaskCancelled", AuditActionSystemQueueTaskCancelled)
register("AuditActionSystemQueueArchivedPurged", AuditActionSystemQueueArchivedPurged)
}
// TestAuditAction_SystemNamespacePrefix pins the system.* actions
// added in this PR to their shared area prefix. The prefix is the
// contract by which the platform audit log endpoint
// (GET /system/admin/audit-log) filters out per-tenant rbac.* rows —
// any drift here would either leak per-tenant events into the
// platform feed or silently hide platform events from SystemAdmin.
func TestAuditAction_SystemNamespacePrefix(t *testing.T) {
cases := []AuditAction{
AuditActionSystemSettingChanged,
AuditActionSystemAdminPromoted,
AuditActionSystemAdminRevoked,
AuditActionSystemUserPasswordReset,
AuditActionSystemUserCreated,
AuditActionSystemQueueTaskRetried,
AuditActionSystemQueueTaskDeleted,
AuditActionSystemQueueTaskRunNow,
AuditActionSystemQueueTaskCancelled,
AuditActionSystemQueueArchivedPurged,
}
for _, a := range cases {
assert.True(t,
strings.HasPrefix(string(a), "system."),
"expected %q to start with 'system.'", a,
)
}
}
// TestAuditAction_SystemWireValues pins the exact wire strings for
// the system.* actions. Audit-log consumers (Langfuse exporters,
// the new frontend platform audit drawer, future SIEM integrations)
// match on these strings; changing them is a breaking change.
func TestAuditAction_SystemWireValues(t *testing.T) {
cases := []struct {
constant AuditAction
wire string
}{
{AuditActionSystemSettingChanged, "system.setting_changed"},
{AuditActionSystemAdminPromoted, "system.admin_promoted"},
{AuditActionSystemAdminRevoked, "system.admin_revoked"},
{AuditActionSystemUserPasswordReset, "system.user_password_reset"},
{AuditActionSystemUserCreated, "system.user_created"},
{AuditActionSystemQueueTaskRetried, "system.queue_task_retried"},
{AuditActionSystemQueueTaskDeleted, "system.queue_task_deleted"},
{AuditActionSystemQueueTaskRunNow, "system.queue_task_run_now"},
{AuditActionSystemQueueTaskCancelled, "system.queue_task_cancelled"},
{AuditActionSystemQueueArchivedPurged, "system.queue_archived_purged"},
}
for _, c := range cases {
assert.Equal(t, c.wire, string(c.constant))
}
}
// TestAuditAction_Phase3WireValues pins the exact wire strings for
// the six new Phase 3 actions. The wire strings are the public
// contract for audit-log consumers; changing them is a breaking
// change.
func TestAuditAction_Phase3WireValues(t *testing.T) {
cases := []struct {
constant AuditAction
wire string
}{
{AuditActionVectorStoreCreated, "vector_store.created"},
{AuditActionVectorStoreUpdated, "vector_store.updated"},
{AuditActionVectorStoreDeleted, "vector_store.deleted"},
{AuditActionOpenSearchIndexCreated, "opensearch.index_created"},
{AuditActionOpenSearchIndexDeleted, "opensearch.index_deleted"},
{AuditActionOpenSearchReindexExecuted, "opensearch.reindex_executed"},
}
for _, c := range cases {
assert.Equal(t, c.wire, string(c.constant))
}
}
+285
View File
@@ -0,0 +1,285 @@
package types
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v3"
)
// ---------------------------------------------------------------------------
// YAML data structures for config/builtin_agents.yaml
// ---------------------------------------------------------------------------
// BuiltinAgentI18n holds localised name and description for a single locale.
type BuiltinAgentI18n struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
}
// BuiltinAgentEntry is one entry in the builtin_agents list in YAML.
type BuiltinAgentEntry struct {
ID string `yaml:"id"`
Avatar string `yaml:"avatar"`
IsBuiltin bool `yaml:"is_builtin"`
I18n map[string]BuiltinAgentI18n `yaml:"i18n"`
Config CustomAgentConfig `yaml:"config"`
}
// builtinAgentsFile is the top-level YAML structure.
type builtinAgentsFile struct {
BuiltinAgents []BuiltinAgentEntry `yaml:"builtin_agents"`
}
// ---------------------------------------------------------------------------
// Global registry (populated from YAML at startup)
// ---------------------------------------------------------------------------
var (
builtinAgentEntries map[string]*BuiltinAgentEntry // keyed by agent ID
builtinAgentEntriesMu sync.RWMutex
builtinAgentEntriesOnce sync.Once
)
// LoadBuiltinAgentsConfig loads built-in agent definitions from the given
// config directory (e.g. "./config"). The file must be named "builtin_agents.yaml".
// This should be called once at startup, after config.LoadConfig determines
// the config directory.
//
// If the file does not exist, the function is a no-op and the hard-coded
// defaults in BuiltinAgentRegistry remain effective.
func LoadBuiltinAgentsConfig(configDir string) error {
var loadErr error
builtinAgentEntriesOnce.Do(func() {
filePath := filepath.Join(configDir, "builtin_agents.yaml")
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
// File not found – perfectly fine, keep using hard-coded defaults.
return
}
loadErr = fmt.Errorf("read builtin_agents.yaml: %w", err)
return
}
var file builtinAgentsFile
if err := yaml.Unmarshal(data, &file); err != nil {
loadErr = fmt.Errorf("parse builtin_agents.yaml: %w", err)
return
}
builtinAgentEntriesMu.Lock()
defer builtinAgentEntriesMu.Unlock()
builtinAgentEntries = make(map[string]*BuiltinAgentEntry, len(file.BuiltinAgents))
for i := range file.BuiltinAgents {
entry := &file.BuiltinAgents[i]
builtinAgentEntries[entry.ID] = entry
}
// Rebuild the BuiltinAgentRegistry so that IsBuiltinAgentID / GetBuiltinAgent
// continue to work transparently.
rebuildRegistryFromConfig()
})
return loadErr
}
// rebuildRegistryFromConfig replaces the BuiltinAgentRegistry entries with
// factory functions that read from the YAML-loaded config. Must be called
// while builtinAgentEntriesMu is held.
func rebuildRegistryFromConfig() {
for id := range builtinAgentEntries {
agentID := id // capture for closure
BuiltinAgentRegistry[agentID] = func(tenantID uint64) *CustomAgent {
return buildAgentFromEntry(agentID, tenantID, "")
}
}
}
// ---------------------------------------------------------------------------
// Public API — context-aware, i18n-capable
// ---------------------------------------------------------------------------
// GetBuiltinAgentWithContext returns a built-in agent whose Name and
// Description are localised according to the language in ctx.
// Falls back to GetBuiltinAgent (default locale) when no YAML config is loaded.
func GetBuiltinAgentWithContext(ctx context.Context, id string, tenantID uint64) *CustomAgent {
locale := localeFromCtx(ctx)
builtinAgentEntriesMu.RLock()
entry, ok := builtinAgentEntries[id]
builtinAgentEntriesMu.RUnlock()
if !ok || entry == nil {
// No YAML entry — fall back to hard-coded factory.
if factory, exists := BuiltinAgentRegistry[id]; exists {
return factory(tenantID)
}
return nil
}
return buildAgentFromEntry(id, tenantID, locale)
}
// ApplyBuiltinAgentLocalization overlays the locale-specific name, description,
// and avatar from builtin_agents.yaml onto a persisted built-in agent.
// Tenant-specific Config and other DB fields are left untouched.
//
// Built-in agents are seeded into the database with the locale that was active
// at first run. ListAgents / GetAgentByID used to return those stored strings
// verbatim, so switching the UI language left the cards in Chinese (or
// whichever language was seeded). YAML already has per-locale copy; this
// applies it on the read path.
func ApplyBuiltinAgentLocalization(ctx context.Context, agent *CustomAgent) {
if agent == nil {
return
}
localized := GetBuiltinAgentWithContext(ctx, agent.ID, agent.TenantID)
if localized == nil {
return
}
if localized.Name != "" {
agent.Name = localized.Name
}
if localized.Description != "" {
agent.Description = localized.Description
}
agent.Avatar = localized.Avatar
}
var builtinAgentEntriesTestMu sync.Mutex
// OverrideBuiltinAgentEntriesForTest replaces the in-memory YAML registry.
// Tests in other packages use this to assert locale overlays; call the
// returned func to restore the previous map. Concurrent tests are serialized
// so they do not clobber each other's entries.
func OverrideBuiltinAgentEntriesForTest(entries map[string]*BuiltinAgentEntry) func() {
builtinAgentEntriesTestMu.Lock()
builtinAgentEntriesMu.Lock()
prev := builtinAgentEntries
builtinAgentEntries = entries
builtinAgentEntriesMu.Unlock()
return func() {
builtinAgentEntriesMu.Lock()
builtinAgentEntries = prev
builtinAgentEntriesMu.Unlock()
builtinAgentEntriesTestMu.Unlock()
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
// buildAgentFromEntry constructs a *CustomAgent from a BuiltinAgentEntry.
// locale can be "" to use the "default" locale.
func buildAgentFromEntry(id string, tenantID uint64, locale string) *CustomAgent {
builtinAgentEntriesMu.RLock()
entry, ok := builtinAgentEntries[id]
builtinAgentEntriesMu.RUnlock()
if !ok || entry == nil {
return nil
}
i18n := resolveI18n(entry.I18n, locale)
agent := &CustomAgent{
ID: entry.ID,
Name: i18n.Name,
Description: i18n.Description,
Avatar: entry.Avatar,
IsBuiltin: entry.IsBuiltin,
TenantID: tenantID,
Config: entry.Config, // value copy
}
agent.EnsureDefaults()
return agent
}
// resolveI18n picks the best locale match from the i18n map.
// Priority: exact match → language-only match → "default" → first entry.
func resolveI18n(m map[string]BuiltinAgentI18n, locale string) BuiltinAgentI18n {
if len(m) == 0 {
return BuiltinAgentI18n{}
}
// 1. Exact match (e.g. "zh-CN")
if v, ok := m[locale]; ok {
return v
}
// 2. Language-only match (e.g. "zh-CN" → try "zh")
if idx := strings.IndexAny(locale, "-_"); idx > 0 {
lang := locale[:idx]
if v, ok := m[lang]; ok {
return v
}
// Also try matching entries that start with the same language prefix
for k, v := range m {
if strings.HasPrefix(k, lang) {
return v
}
}
}
// 3. "default" key
if v, ok := m["default"]; ok {
return v
}
// 4. First available entry
for _, v := range m {
return v
}
return BuiltinAgentI18n{}
}
// localeFromCtx extracts the locale string from ctx, falling back to "".
func localeFromCtx(ctx context.Context) string {
if ctx == nil {
return ""
}
lang, _ := LanguageFromContext(ctx)
return lang
}
// ResolveBuiltinAgentPromptRefs iterates over all builtin agent entries and
// resolves system_prompt_id / context_template_id references by calling the
// provided resolver function. The resolver takes a template ID and returns
// the template content string (empty string if not found).
//
// This must be called after both LoadBuiltinAgentsConfig and prompt template
// loading have completed.
func ResolveBuiltinAgentPromptRefs(resolver func(id string) string) {
builtinAgentEntriesMu.Lock()
defer builtinAgentEntriesMu.Unlock()
for _, entry := range builtinAgentEntries {
if entry == nil {
continue
}
// Resolve system_prompt_id → SystemPrompt
if entry.Config.SystemPromptID != "" && entry.Config.SystemPrompt == "" {
if content := resolver(entry.Config.SystemPromptID); content != "" {
entry.Config.SystemPrompt = content
} else {
fmt.Printf("Warning: builtin agent %q references system_prompt_id %q but template not found\n",
entry.ID, entry.Config.SystemPromptID)
}
}
// Resolve context_template_id → ContextTemplate
if entry.Config.ContextTemplateID != "" && entry.Config.ContextTemplate == "" {
if content := resolver(entry.Config.ContextTemplateID); content != "" {
entry.Config.ContextTemplate = content
} else {
fmt.Printf("Warning: builtin agent %q references context_template_id %q but template not found\n",
entry.ID, entry.Config.ContextTemplateID)
}
}
}
}
@@ -0,0 +1,55 @@
package types
import (
"context"
"testing"
)
func TestApplyBuiltinAgentLocalizationOverlaysYAMLLocale(t *testing.T) {
restore := OverrideBuiltinAgentEntriesForTest(map[string]*BuiltinAgentEntry{
BuiltinQuickAnswerID: {
ID: BuiltinQuickAnswerID,
Avatar: "quick.png",
I18n: map[string]BuiltinAgentI18n{
"default": {Name: "快速问答", Description: "中文 RAG"},
"en-US": {Name: "Quick Answer", Description: "Knowledge base RAG Q&A"},
"zh-CN": {Name: "快速问答", Description: "中文 RAG"},
},
},
})
t.Cleanup(restore)
agent := &CustomAgent{
ID: BuiltinQuickAnswerID,
Name: "快速问答",
Description: "中文 RAG",
Avatar: "",
IsBuiltin: true,
TenantID: 1,
}
ctx := context.WithValue(context.Background(), LanguageContextKey, "en-US")
ApplyBuiltinAgentLocalization(ctx, agent)
if agent.Name != "Quick Answer" {
t.Fatalf("Name = %q, want Quick Answer", agent.Name)
}
if agent.Description != "Knowledge base RAG Q&A" {
t.Fatalf("Description = %q, want English copy", agent.Description)
}
if agent.Avatar != "quick.png" {
t.Fatalf("Avatar = %q, want quick.png", agent.Avatar)
}
}
func TestApplyBuiltinAgentLocalizationNilSafe(t *testing.T) {
ApplyBuiltinAgentLocalization(context.Background(), nil)
}
func TestApplyBuiltinAgentLocalizationLeavesUnknownAgents(t *testing.T) {
agent := &CustomAgent{ID: "custom-agent-1", Name: "Mine", Description: "keep me"}
ctx := context.WithValue(context.Background(), LanguageContextKey, "en-US")
ApplyBuiltinAgentLocalization(ctx, agent)
if agent.Name != "Mine" || agent.Description != "keep me" {
t.Fatalf("custom agent was rewritten: %+v", agent)
}
}
+320
View File
@@ -0,0 +1,320 @@
package types
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"gopkg.in/yaml.v3"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Logging via stdlib `log` (with a [builtin-models] prefix) instead of the
// project's structured logger because `internal/logger` itself imports
// `internal/types`, creating an import cycle if we use it from here. The
// same constraint forces sibling files in this package (e.g. model.go's
// crypto error logs) to use stdlib log too. The prefix gives grep/Splunk
// users a stable handle even though the lines are unstructured.
// BuiltinModelManagedBy is the value written into `models.managed_by` for
// rows whose lifecycle is owned by config/builtin_models.yaml. Other rows
// (UI / API / hand-written SQL) keep the column at its default empty string
// and are never touched by the YAML loader.
const BuiltinModelManagedBy = "yaml"
// BuiltinModelEntry mirrors one entry in builtin_models.yaml.
// Each entry becomes a row in the models table with is_builtin=true.
type BuiltinModelEntry struct {
ID string `yaml:"id"`
TenantID uint64 `yaml:"tenant_id"`
Name string `yaml:"name"`
Type ModelType `yaml:"type"`
Source ModelSource `yaml:"source"`
Description string `yaml:"description"`
IsDefault bool `yaml:"is_default"`
Status ModelStatus `yaml:"status"`
Parameters ModelParameters `yaml:"parameters"`
}
type builtinModelsFile struct {
BuiltinModels []BuiltinModelEntry `yaml:"builtin_models"`
}
// builtinModelEnvPattern matches ${NAME} placeholders. Mirrors the pattern in
// internal/config/config.go so YAML interpolation behaves identically to the
// main config.yaml flow.
var builtinModelEnvPattern = regexp.MustCompile(`\${([^}]+)}`)
// interpolateBuiltinModelEnv substitutes ${NAME} occurrences with the
// corresponding os.Getenv value. Unset vars are left as the literal ${NAME}
// so misconfiguration surfaces visibly in downstream provider calls instead
// of failing silently with an empty token.
func interpolateBuiltinModelEnv(s string) string {
return builtinModelEnvPattern.ReplaceAllStringFunc(s, func(m string) string {
name := m[2 : len(m)-1]
if v := os.Getenv(name); v != "" {
return v
}
return m
})
}
// LoadBuiltinModelsConfig reads builtin_models.yaml (or the path pointed to by
// BUILTIN_MODELS_CONFIG) and reconciles the YAML-managed slice of `models`
// with the file contents.
//
// Lifecycle contract:
// - YAML loader only ever writes rows tagged managed_by="yaml". Rows created
// or explicitly overridden via UI/API/SQL (managed_by="") are detected by
// id and preserved, and are never modified.
// - Each YAML entry is UPSERTed by id, with deleted_at force-reset to NULL
// (so a row that was soft-deleted previously is resurrected when it
// reappears in the file).
// - After all UPSERTs, any pre-existing yaml-managed row whose id is no
// longer in the file is soft-deleted. Removing an entry from YAML is
// therefore the supported way to retire a built-in model — no manual
// SQL needed.
// - If is_default=true is set on a YAML entry, the loader first clears
// is_default on any other rows in the same (tenant_id, type) bucket,
// mirroring the invariant enforced by the API path. Multiple entries
// with is_default=true for the same bucket result in last-one-wins with
// a warning.
//
// Failure handling:
// - file not found / mount point is a directory / path unset: no-op
// - YAML parse error: prints a warning and aborts the reconcile (the
// drift sweep is NOT run, so a malformed file cannot accidentally wipe
// YAML-managed rows)
// - per-entry UPSERT error: prints a warning, the entry is dropped from
// the "current YAML id set" so the sweep won't delete its existing
// row either (treats the failure as "leave alone")
func LoadBuiltinModelsConfig(ctx context.Context, db *gorm.DB, configDir string) error {
path := os.Getenv("BUILTIN_MODELS_CONFIG")
if path == "" {
path = filepath.Join(configDir, "builtin_models.yaml")
}
// Treat "missing", "is a directory", and other non-regular-file cases the
// same way. Docker bind-mounting a non-existent source file silently
// substitutes a directory; we don't want that to spam WARN logs.
info, statErr := os.Stat(path)
if statErr != nil || !info.Mode().IsRegular() {
log.Printf("[builtin-models] config not present at %s; skipping", path)
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
log.Printf("[builtin-models] WARN: read config %s failed: %v; skipping", path, err)
return nil
}
expanded := interpolateBuiltinModelEnv(string(raw))
var file builtinModelsFile
if err := yaml.Unmarshal([]byte(expanded), &file); err != nil {
log.Printf("[builtin-models] WARN: parse config %s failed: %v; skipping reconcile", path, err)
return nil
}
// yamlIDs collects every id we successfully upserted this run. Used by
// the drift sweep below to determine which previously-yaml-managed rows
// have disappeared and should be retired.
yamlIDs := make([]string, 0, len(file.BuiltinModels))
applied := 0
for i := range file.BuiltinModels {
e := &file.BuiltinModels[i]
if err := validateBuiltinModelEntry(e, i); err != nil {
log.Printf("[builtin-models] WARN: %v; skipping", err)
continue
}
m := e.toModel()
// A system-admin edit clears managed_by to claim the row as a runtime
// override. Preserve that explicit override on subsequent starts rather
// than reclaiming and overwriting it from YAML. Unscoped is required so
// a manually managed soft-deleted collision is not resurrected either.
var existing Model
lookupErr := db.WithContext(ctx).Unscoped().
Select("id", "managed_by").
Where("id = ?", m.ID).
First(&existing).Error
if lookupErr == nil && existing.ManagedBy != BuiltinModelManagedBy {
log.Printf("[builtin-models] preserving runtime override: id=%s", m.ID)
continue
}
if lookupErr != nil && lookupErr != gorm.ErrRecordNotFound {
log.Printf("[builtin-models] WARN: inspect existing model %s failed: %v; skipping", m.ID, lookupErr)
continue
}
// Mirror the API path's "single default per (tenant_id, type)"
// invariant: clear other defaults before promoting this one.
// Excluded our own id so we don't churn against ourselves.
if m.IsDefault {
if err := db.WithContext(ctx).
Model(&Model{}).
Where("tenant_id = ? AND type = ? AND id <> ? AND is_default = ?",
m.TenantID, m.Type, m.ID, true).
Update("is_default", false).Error; err != nil {
log.Printf("[builtin-models] WARN: clear existing default for tenant=%d type=%s failed: %v; continuing",
m.TenantID, m.Type, err)
}
}
res := db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.AssignmentColumns([]string{
"tenant_id", "name", "type", "source", "description",
"parameters", "is_default", "status", "is_builtin",
"managed_by", "deleted_at", "updated_at",
}),
}).Create(&m)
if res.Error != nil {
log.Printf("[builtin-models] WARN: upsert %s failed: %v; continuing", e.ID, res.Error)
continue
}
applied++
yamlIDs = append(yamlIDs, e.ID)
log.Printf("[builtin-models] upserted: id=%s name=%s type=%s", e.ID, e.Name, e.Type)
}
// Drift sweep: retire YAML-managed rows that no longer appear in the
// file. Manual rows (managed_by='') are untouched. Uses GORM soft delete
// so the row remains recoverable.
pruned, sweepErr := pruneOrphanYAMLManagedModels(ctx, db, yamlIDs)
if sweepErr != nil {
log.Printf("[builtin-models] WARN: drift sweep failed: %v; continuing", sweepErr)
}
log.Printf("[builtin-models] applied: %d upserted, %d pruned from %s", applied, pruned, path)
return nil
}
// pruneOrphanYAMLManagedModels soft-deletes rows where managed_by='yaml'
// and id is NOT in keepIDs. Used to retire entries that have been removed
// from builtin_models.yaml. Returns the number of rows affected.
//
// Safety:
// - Only rows tagged managed_by='yaml' are eligible — manual rows are
// never visible to this query.
// - Already-soft-deleted rows are skipped (GORM default scope), so this
// is idempotent across restarts.
// - When keepIDs is empty the sweep retires ALL yaml-managed rows. That
// matches the natural reading of "the YAML file declares zero entries
// ⇒ no yaml-managed models should exist". The caller has already
// short-circuited on parse failure, so we only reach this branch when
// the operator deliberately reduced the file to an empty list.
func pruneOrphanYAMLManagedModels(ctx context.Context, db *gorm.DB, keepIDs []string) (int64, error) {
q := db.WithContext(ctx).
Where("managed_by = ?", BuiltinModelManagedBy)
if len(keepIDs) > 0 {
q = q.Where("id NOT IN ?", keepIDs)
}
res := q.Delete(&Model{})
return res.RowsAffected, res.Error
}
// validBuiltinModelTypes is the set of model types the loader accepts.
// Mirrors the ModelType* constants. Anything else is rejected with a
// warning rather than persisted, because provider factories match types
// case-sensitively and a misspelled YAML entry would produce a row that
// looks present in the table but is unusable downstream.
var validBuiltinModelTypes = map[ModelType]struct{}{
ModelTypeKnowledgeQA: {},
ModelTypeEmbedding: {},
ModelTypeRerank: {},
ModelTypeVLLM: {},
ModelTypeASR: {},
}
// validBuiltinModelStatuses is the set of statuses the loader accepts.
// Empty is allowed and is normalized to ModelStatusActive in toModel().
var validBuiltinModelStatuses = map[ModelStatus]struct{}{
ModelStatusActive: {},
ModelStatusDownloading: {},
ModelStatusDownloadFailed: {},
}
// validateBuiltinModelEntry returns nil if the entry is loadable, or an
// error describing what's wrong. Catches the failure modes that would
// either crash the INSERT or silently produce an unusable row:
//
// - empty id (cannot UPSERT)
// - id longer than the DB column (PG/SQLite cap at varchar(64),
// see ModelIDMaxLen) which would fail at INSERT time
// - empty or misspelled type (provider factories match exact strings)
// - explicit non-empty status outside the known set
//
// Source is intentionally NOT validated against a fixed list because the
// provider matrix in internal/models/* keeps growing and a too-strict
// check here would force changes in two places per new provider.
func validateBuiltinModelEntry(e *BuiltinModelEntry, index int) error {
if e.ID == "" {
return errBuiltinModel("entry %d has empty id", index)
}
if len(e.ID) > ModelIDMaxLen {
return errBuiltinModel("entry %d id %q exceeds %d-char DB limit (got %d)",
index, e.ID, ModelIDMaxLen, len(e.ID))
}
if e.Type == "" {
return errBuiltinModel("entry %d (%s) missing required field 'type'", index, e.ID)
}
if _, ok := validBuiltinModelTypes[e.Type]; !ok {
return errBuiltinModel(
"entry %d (%s) has unknown type %q (expected one of: KnowledgeQA, Embedding, Rerank, VLLM, ASR)",
index, e.ID, e.Type)
}
if e.Status != "" {
if _, ok := validBuiltinModelStatuses[e.Status]; !ok {
return errBuiltinModel(
"entry %d (%s) has unknown status %q (expected: active, downloading, download_failed, or empty)",
index, e.ID, e.Status)
}
}
return nil
}
// errBuiltinModel formats a validation error with a stable prefix so log
// output stays consistent with the rest of this package's warnings.
func errBuiltinModel(format string, args ...interface{}) error {
return fmt.Errorf(format, args...)
}
// toModel converts a YAML entry to a runtime Model with sensible defaults.
// tenant_id defaults to DefaultBuiltinModelTenantID (10000, matching the
// seed value of tenants_id_seq); source defaults to "remote"; status
// defaults to "active". IsBuiltin and ManagedBy are always forced
// regardless of YAML input.
func (e *BuiltinModelEntry) toModel() Model {
tenantID := e.TenantID
if tenantID == 0 {
tenantID = DefaultBuiltinModelTenantID
}
source := e.Source
if source == "" {
source = ModelSourceRemote
}
status := e.Status
if status == "" {
status = ModelStatusActive
}
return Model{
ID: e.ID,
TenantID: tenantID,
Name: e.Name,
Type: e.Type,
Source: source,
Description: e.Description,
Parameters: e.Parameters,
IsDefault: e.IsDefault,
IsBuiltin: true,
ManagedBy: BuiltinModelManagedBy,
Status: status,
}
}
@@ -0,0 +1,379 @@
package types
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupBuiltinModelsDB creates an in-memory SQLite DB with `models` migrated
// via GORM AutoMigrate. AutoMigrate honours the struct tags so the
// `managed_by` and soft-delete columns are present, matching what the real
// migrations produce.
func setupBuiltinModelsDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&Model{}))
return db
}
// writeYAML writes a builtin_models.yaml file inside a fresh temp dir and
// returns the dir (which is what LoadBuiltinModelsConfig expects as its
// configDir argument).
func writeYAML(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "builtin_models.yaml")
require.NoError(t, os.WriteFile(path, []byte(body), 0o644))
return dir
}
// countModels returns (live, soft-deleted) counts for the given id, used to
// distinguish "row gone via UPSERT failure" from "row soft-deleted via sweep".
func countModels(t *testing.T, db *gorm.DB, id string) (live, deleted int64) {
t.Helper()
require.NoError(t, db.Model(&Model{}).Where("id = ?", id).Count(&live).Error)
require.NoError(t,
db.Unscoped().Model(&Model{}).Where("id = ? AND deleted_at IS NOT NULL", id).Count(&deleted).Error,
)
return live, deleted
}
func TestLoadBuiltinModelsConfig_FileMissing(t *testing.T) {
db := setupBuiltinModelsDB(t)
dir := t.TempDir() // empty — no builtin_models.yaml inside
// Pre-seed a yaml-managed row to verify it is NOT touched when the
// config file is absent (file-missing path must skip the sweep).
require.NoError(t, db.Create(&Model{
ID: "pre-existing-yaml", Name: "n", Type: ModelTypeKnowledgeQA,
Source: ModelSourceRemote, Status: ModelStatusActive,
IsBuiltin: true, ManagedBy: BuiltinModelManagedBy,
}).Error)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
live, deleted := countModels(t, db, "pre-existing-yaml")
assert.Equal(t, int64(1), live, "row must remain when YAML file is absent")
assert.Equal(t, int64(0), deleted)
}
func TestLoadBuiltinModelsConfig_ParseError(t *testing.T) {
db := setupBuiltinModelsDB(t)
// Malformed YAML — should warn + return without sweeping.
dir := writeYAML(t, "builtin_models: [oops: : bad")
require.NoError(t, db.Create(&Model{
ID: "pre-existing-yaml", Name: "n", Type: ModelTypeKnowledgeQA,
Source: ModelSourceRemote, Status: ModelStatusActive,
IsBuiltin: true, ManagedBy: BuiltinModelManagedBy,
}).Error)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
live, deleted := countModels(t, db, "pre-existing-yaml")
assert.Equal(t, int64(1), live, "parse error must NOT trigger sweep")
assert.Equal(t, int64(0), deleted)
}
func TestLoadBuiltinModelsConfig_BasicUpsert(t *testing.T) {
db := setupBuiltinModelsDB(t)
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
is_default: true
parameters:
provider: openai
api_key: sk-123
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var m Model
require.NoError(t, db.Where("id = ?", "builtin-llm").First(&m).Error)
assert.Equal(t, "gpt-4o-mini", m.Name)
assert.Equal(t, ModelTypeKnowledgeQA, m.Type)
assert.Equal(t, ModelSourceRemote, m.Source, "default source must be remote")
assert.Equal(t, ModelStatusActive, m.Status, "default status must be active")
assert.Equal(t, uint64(10000), m.TenantID, "default tenant_id must be 10000")
assert.True(t, m.IsBuiltin, "IsBuiltin must be forced to true")
assert.True(t, m.IsDefault)
assert.Equal(t, BuiltinModelManagedBy, m.ManagedBy)
assert.Equal(t, "openai", m.Parameters.Provider)
assert.Equal(t, "sk-123", m.Parameters.APIKey)
}
func TestLoadBuiltinModelsConfig_Idempotent(t *testing.T) {
db := setupBuiltinModelsDB(t)
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
parameters:
provider: openai
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var count int64
require.NoError(t, db.Model(&Model{}).Where("id = ?", "builtin-llm").Count(&count).Error)
assert.Equal(t, int64(1), count, "second load must not duplicate")
}
func TestLoadBuiltinModelsConfig_PreservesRuntimeOverride(t *testing.T) {
db := setupBuiltinModelsDB(t)
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: from-yaml
type: KnowledgeQA
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
// System-admin edits clear managed_by. A later startup must not reclaim
// that row even when the same id remains declared in YAML.
require.NoError(t, db.Model(&Model{}).Where("id = ?", "builtin-llm").Updates(map[string]any{
"name": "from-admin-ui",
"managed_by": "",
}).Error)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var m Model
require.NoError(t, db.Where("id = ?", "builtin-llm").First(&m).Error)
assert.Equal(t, "from-admin-ui", m.Name)
assert.Empty(t, m.ManagedBy)
}
func TestLoadBuiltinModelsConfig_EnvInterpolation(t *testing.T) {
db := setupBuiltinModelsDB(t)
t.Setenv("BUILTIN_TEST_KEY", "sk-from-env")
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
parameters:
provider: openai
api_key: ${BUILTIN_TEST_KEY}
base_url: ${BUILTIN_TEST_UNSET}
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var m Model
require.NoError(t, db.Where("id = ?", "builtin-llm").First(&m).Error)
assert.Equal(t, "sk-from-env", m.Parameters.APIKey, "set env var must be substituted")
assert.Equal(t, "${BUILTIN_TEST_UNSET}", m.Parameters.BaseURL,
"unset env var must be kept as literal placeholder")
}
func TestLoadBuiltinModelsConfig_DriftSweepRemovesYAMLManaged(t *testing.T) {
db := setupBuiltinModelsDB(t)
// Round 1: YAML declares two entries.
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
- id: builtin-rerank
name: bge
type: Rerank
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
// Round 2: rerank entry removed.
require.NoError(t, os.WriteFile(
filepath.Join(dir, "builtin_models.yaml"),
[]byte(`builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
`), 0o644,
))
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
live, deleted := countModels(t, db, "builtin-rerank")
assert.Equal(t, int64(0), live, "rerank row must be soft-deleted")
assert.Equal(t, int64(1), deleted, "rerank row must remain recoverable in raw table")
live, _ = countModels(t, db, "builtin-llm")
assert.Equal(t, int64(1), live, "llm row must survive the sweep")
}
func TestLoadBuiltinModelsConfig_DriftSweepIgnoresManual(t *testing.T) {
db := setupBuiltinModelsDB(t)
// Seed a manual SQL-style builtin (managed_by="").
require.NoError(t, db.Create(&Model{
ID: "manual-builtin", Name: "manual", Type: ModelTypeKnowledgeQA,
Source: ModelSourceRemote, Status: ModelStatusActive,
IsBuiltin: true, ManagedBy: "",
}).Error)
// YAML declares a different id only. Sweep must NOT touch manual row.
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
live, _ := countModels(t, db, "manual-builtin")
assert.Equal(t, int64(1), live, "manual SQL-seeded builtin must survive YAML sweep")
}
func TestLoadBuiltinModelsConfig_ResurrectsSoftDeleted(t *testing.T) {
db := setupBuiltinModelsDB(t)
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
// Simulate operator soft-deleting the row via API/UI.
require.NoError(t, db.Where("id = ?", "builtin-llm").Delete(&Model{}).Error)
live, deleted := countModels(t, db, "builtin-llm")
require.Equal(t, int64(0), live)
require.Equal(t, int64(1), deleted)
// Re-running the loader must resurrect the row (deleted_at -> NULL).
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
live, deleted = countModels(t, db, "builtin-llm")
assert.Equal(t, int64(1), live, "row must be resurrected by UPSERT")
assert.Equal(t, int64(0), deleted, "deleted_at must be cleared")
}
func TestLoadBuiltinModelsConfig_ClearsExistingDefault(t *testing.T) {
db := setupBuiltinModelsDB(t)
// Seed an existing default (manual, e.g. user picked it via UI).
require.NoError(t, db.Create(&Model{
ID: "user-default", Name: "user", Type: ModelTypeKnowledgeQA,
TenantID: 10000, Source: ModelSourceRemote, Status: ModelStatusActive,
IsDefault: true, IsBuiltin: false, ManagedBy: "",
}).Error)
dir := writeYAML(t, `builtin_models:
- id: builtin-llm
name: gpt-4o-mini
type: KnowledgeQA
is_default: true
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var prev Model
require.NoError(t, db.Where("id = ?", "user-default").First(&prev).Error)
assert.False(t, prev.IsDefault,
"YAML is_default=true must clear other defaults in same (tenant,type)")
var newDefault Model
require.NoError(t, db.Where("id = ?", "builtin-llm").First(&newDefault).Error)
assert.True(t, newDefault.IsDefault)
}
func TestLoadBuiltinModelsConfig_EmptyListSweepsAllYAMLManaged(t *testing.T) {
db := setupBuiltinModelsDB(t)
// Seed one yaml-managed and one manual builtin.
require.NoError(t, db.Create(&Model{
ID: "yaml-old", Name: "y", Type: ModelTypeKnowledgeQA,
Source: ModelSourceRemote, Status: ModelStatusActive,
IsBuiltin: true, ManagedBy: BuiltinModelManagedBy,
}).Error)
require.NoError(t, db.Create(&Model{
ID: "manual-builtin", Name: "m", Type: ModelTypeKnowledgeQA,
Source: ModelSourceRemote, Status: ModelStatusActive,
IsBuiltin: true, ManagedBy: "",
}).Error)
// Explicit empty list. Loader treats this as "no YAML-managed models
// declared" and sweeps the entire yaml-managed slice. Manual rows are
// untouched.
dir := writeYAML(t, "builtin_models: []\n")
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
live, _ := countModels(t, db, "yaml-old")
assert.Equal(t, int64(0), live, "yaml-managed row must be swept")
live, _ = countModels(t, db, "manual-builtin")
assert.Equal(t, int64(1), live, "manual row must survive empty-list sweep")
}
func TestLoadBuiltinModelsConfig_RejectsInvalidEntries(t *testing.T) {
// Each sub-case writes a single-entry YAML and asserts the resulting
// `models` table stays empty (entry was rejected by validator).
cases := []struct {
name string
yaml string
}{
{
name: "id too long",
yaml: "builtin_models:\n - id: " + repeatChar("a", ModelIDMaxLen+1) +
"\n type: KnowledgeQA\n",
},
{
name: "missing type",
yaml: "builtin_models:\n - id: builtin-x\n name: x\n",
},
{
name: "unknown type (case mismatch)",
yaml: "builtin_models:\n - id: builtin-x\n type: knowledgeqa\n",
},
{
name: "unknown type",
yaml: "builtin_models:\n - id: builtin-x\n type: LLM\n",
},
{
name: "unknown status",
yaml: "builtin_models:\n - id: builtin-x\n type: KnowledgeQA\n status: enabled\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
db := setupBuiltinModelsDB(t)
dir := writeYAML(t, c.yaml)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var count int64
require.NoError(t, db.Model(&Model{}).Count(&count).Error)
assert.Equal(t, int64(0), count,
"invalid entry must be rejected, table must stay empty")
})
}
}
// repeatChar returns s repeated n times. Used to synthesize id strings
// that exceed ModelIDMaxLen without depending on strings.Repeat in tests.
func repeatChar(s string, n int) string {
out := make([]byte, 0, len(s)*n)
for i := 0; i < n; i++ {
out = append(out, s...)
}
return string(out)
}
func TestLoadBuiltinModelsConfig_PreservesEntryIDOverBeforeCreate(t *testing.T) {
// Regression guard: BeforeCreate now only generates a UUID when ID is
// empty. YAML always supplies a stable id; if BeforeCreate ever forgets
// the guard and overwrites it, the UPSERT key changes and idempotency
// breaks. Reload the file twice and assert the same row id survives.
db := setupBuiltinModelsDB(t)
dir := writeYAML(t, `builtin_models:
- id: stable-id-123
name: n
type: KnowledgeQA
`)
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
require.NoError(t, LoadBuiltinModelsConfig(context.Background(), db, dir))
var ids []string
require.NoError(t, db.Model(&Model{}).Pluck("id", &ids).Error)
assert.Equal(t, []string{"stable-id-123"}, ids,
"YAML-declared id must survive across reloads (no UUID regeneration)")
}
+305
View File
@@ -0,0 +1,305 @@
package types
import (
"database/sql/driver"
"encoding/json"
)
// PromptCacheStatus distinguishes a real cache miss from providers that do not
// expose prompt-cache accounting. Treating both as zero makes fleet-level hit
// rate dashboards misleading.
type PromptCacheStatus string
const (
PromptCacheStatusUnsupported PromptCacheStatus = "unsupported"
PromptCacheStatusUnreported PromptCacheStatus = "unreported"
PromptCacheStatusMiss PromptCacheStatus = "miss"
PromptCacheStatusHit PromptCacheStatus = "hit"
)
// TokenUsage holds token consumption statistics returned by the model API.
type TokenUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
// CachedTokens is the legacy alias for CacheReadTokens. It remains on the
// wire for compatibility with existing API consumers.
CachedTokens int `json:"cached_tokens,omitempty"`
CacheReadTokens int `json:"cache_read_tokens,omitempty"`
CacheWriteTokens int `json:"cache_write_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
CacheReported bool `json:"cache_reported"`
CacheStatus PromptCacheStatus `json:"cache_status,omitempty"`
}
// SetPromptCacheUsage normalizes provider-specific cache counters into the
// shared usage model. promptTokens remains the provider's total input-token
// count; read/write/miss are descriptive subsets and must not be added to it.
func (u *TokenUsage) SetPromptCacheUsage(read, write, miss int, reported bool) {
if u == nil {
return
}
if read < 0 {
read = 0
}
if write < 0 {
write = 0
}
if miss < 0 {
miss = 0
}
u.CachedTokens = read
u.CacheReadTokens = read
u.CacheWriteTokens = write
u.CacheMissTokens = miss
u.CacheReported = reported
switch {
case !reported:
u.CacheStatus = PromptCacheStatusUnreported
case read > 0:
u.CacheStatus = PromptCacheStatusHit
default:
u.CacheStatus = PromptCacheStatusMiss
}
}
// MarkPromptCacheUnsupported marks a provider/model path that cannot report
// provider-side prompt-cache usage.
func (u *TokenUsage) MarkPromptCacheUnsupported() {
if u == nil {
return
}
u.SetPromptCacheUsage(0, 0, 0, false)
u.CacheStatus = PromptCacheStatusUnsupported
}
// Accumulate adds another call's usage into u, preserving the subset
// semantics: prompt/completion/total and every cache counter sum
// independently (cache counters stay subsets of the prompt count and are
// never folded into it). CacheReported ORs, and the cache status is
// recomputed from the combined counters so a single cache hit anywhere in
// the accumulated calls reads as a hit.
func (u *TokenUsage) Accumulate(other TokenUsage) {
if u == nil {
return
}
u.PromptTokens += other.PromptTokens
u.CompletionTokens += other.CompletionTokens
u.TotalTokens += other.TotalTokens
u.CachedTokens += other.CachedTokens
u.CacheReadTokens += other.CacheReadTokens
u.CacheWriteTokens += other.CacheWriteTokens
u.CacheMissTokens += other.CacheMissTokens
u.CacheReported = u.CacheReported || other.CacheReported
switch {
case !u.CacheReported:
u.CacheStatus = mergeUnreportedCacheStatus(u.CacheStatus, other.CacheStatus)
case u.CacheReadTokens > 0:
u.CacheStatus = PromptCacheStatusHit
default:
u.CacheStatus = PromptCacheStatusMiss
}
}
// mergeUnreportedCacheStatus folds the statuses of never-reported usage.
// "unsupported" survives only while every accumulated call was itself
// classified unsupported — the first accumulation adopts the incoming
// classification, and any later call that is not known-unsupported (an
// unreported or unclassified one) degrades the aggregate to unreported.
func mergeUnreportedCacheStatus(accumulated, incoming PromptCacheStatus) PromptCacheStatus {
if accumulated == "" {
if incoming == "" {
return PromptCacheStatusUnreported
}
return incoming
}
if accumulated == PromptCacheStatusUnsupported && incoming == PromptCacheStatusUnsupported {
return PromptCacheStatusUnsupported
}
return PromptCacheStatusUnreported
}
// PromptCacheHitRate returns cache-read tokens as a percentage of the
// provider's prompt total. Zero when the prompt is empty. A ReAct turn that
// reuses the system prefix therefore reads as a high hit rather than a miss.
func (u TokenUsage) PromptCacheHitRate() float64 {
if u.PromptTokens <= 0 {
return 0
}
return float64(u.CacheReadTokens) / float64(u.PromptTokens) * 100
}
// Value persists the usage as a jsonb column (assistant messages carry the
// turn's aggregate); nil writes SQL NULL. Mirrors the nullable-pointer
// pattern APIPrincipalConfig uses.
func (u *TokenUsage) Value() (driver.Value, error) {
if u == nil {
return nil, nil
}
return json.Marshal(u)
}
// Scan restores a jsonb usage column; NULL leaves the receiver zero-valued.
func (u *TokenUsage) Scan(value interface{}) error {
if value == nil {
return nil
}
var b []byte
switch v := value.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return nil
}
return json.Unmarshal(b, u)
}
// LLMToolCall represents a function/tool call from the LLM
type LLMToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // "function"
Function FunctionCall `json:"function"`
ProviderMetadata ToolCallMetadata `json:"provider_metadata,omitempty"`
// ModelArguments and the resolution fields are request-local observability
// state. ModelArguments preserves the exact JSON emitted by the model while
// Function.Arguments is decoded to durable application identifiers before
// tool execution. These fields must never be sent back to a provider or
// persisted in chat history.
ModelArguments string `json:"-"`
ArgumentResolution string `json:"-"`
UnresolvedHandles []string `json:"-"`
}
// ToolCallMetadata carries provider-specific tool-call state that must round-trip
// with the assistant tool call, without teaching core agent code vendor fields.
type ToolCallMetadata map[string]json.RawMessage
// FunctionCall represents the function details
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON string
}
// ChatResponse chat response
type ChatResponse struct {
Content string `json:"content"`
// ReasoningContent 是支持思考链的模型(DeepSeek thinking、小米 MiMo、vLLM reasoning 等)
// 在本轮输出的推理内容。需要在后续多轮请求中原样回传给那些严格校验的供应商。
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []LLMToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
Usage TokenUsage `json:"usage"`
// AnswerStreamed reports whether the user-facing answer text was already
// streamed live to the final-answer UI area during this round (i.e. the
// model answered with plain content). When true, the natural-stop branch
// must only emit the closing
// Done marker for AnswerEventID instead of re-emitting the whole answer —
// otherwise the answer would render twice and "jump" at end of stream.
// Transient, never persisted.
AnswerStreamed bool `json:"-"`
// AnswerEventID is the EventBus event ID under which the live answer
// chunks were streamed, so the natural-stop branch can close the same
// stream with a Done marker. Empty when AnswerStreamed is false.
AnswerEventID string `json:"-"`
}
// Response type
type ResponseType string
const (
// Answer response type
ResponseTypeAnswer ResponseType = "answer"
// References response type
ResponseTypeReferences ResponseType = "references"
// Thinking response type (for agent thought process)
ResponseTypeThinking ResponseType = "thinking"
// Tool call response type (for agent tool invocations)
ResponseTypeToolCall ResponseType = "tool_call"
// Tool result response type (for agent tool results)
ResponseTypeToolResult ResponseType = "tool_result"
// Error response type
ResponseTypeError ResponseType = "error"
// Reflection response type (for agent reflection)
ResponseTypeReflection ResponseType = "reflection"
// Session title response type
ResponseTypeSessionTitle ResponseType = "session_title"
// Agent query response type (query received and processing started)
ResponseTypeAgentQuery ResponseType = "agent_query"
// Complete response type (agent complete)
ResponseTypeComplete ResponseType = "complete"
// ResponseTypeArtifactsPending is sent while skill/sandbox output is being
// copied into persistent storage after the answer has already streamed.
// The UI shows a toolbar placeholder until ResponseTypeComplete carries
// the file list.
ResponseTypeArtifactsPending ResponseType = "artifacts_pending"
// ToolApprovalRequired: MCP tool marked dangerous — UI must collect user approval before execution continues
ResponseTypeToolApprovalRequired ResponseType = "tool_approval_required"
// ToolApprovalResolved: user approved/rejected (or timeout); informational for UI replay
ResponseTypeToolApprovalResolved ResponseType = "tool_approval_resolved"
// MCPOAuthRequired: an OAuth-enabled MCP service was invoked but the user
// has not authorized it — UI must surface an "Authorize" prompt and the
// agent pauses until authorization completes (or the wait times out).
ResponseTypeMCPOAuthRequired ResponseType = "mcp_oauth_required"
// MCPOAuthResolved: authorization completed / timed out / canceled;
// informational for UI replay.
ResponseTypeMCPOAuthResolved ResponseType = "mcp_oauth_resolved"
// MemoryRecalled: the long-term memories injected into this answer, so
// the UI can show and let the user delete what influenced it.
ResponseTypeMemoryRecalled ResponseType = "memory_recalled"
// ResponseTypeContextCompacted is older conversation summarized away to
// fit the context window. Surfaced because it changes what the agent
// remembers — an answer that forgets an earlier instruction is otherwise
// indistinguishable from the model ignoring it.
ResponseTypeContextCompacted ResponseType = "context_compacted"
// ResponseTypeInstallPrompt is the instruction a skill install handed to
// the installer agent. Only the skill install transcript emits this, and
// it emits it first, so replaying the log alone shows what was asked for
// — the console does not have to read the durable prompt row to caption
// the run.
ResponseTypeInstallPrompt ResponseType = "install_prompt"
)
// StreamResponse stream response
// FinishReasonIncomplete marks a stream that broke before the provider sent a
// finish reason (read error, timeout, stall). Whatever content and tool calls
// were collected are a partial response: callers must not treat them as a
// completed turn.
const FinishReasonIncomplete = "incomplete"
type StreamResponse struct {
ID string `json:"id"`
ResponseType ResponseType `json:"response_type"`
Content string `json:"content"`
Done bool `json:"done"`
KnowledgeReferences References `json:"knowledge_references,omitempty"`
SessionID string `json:"session_id,omitempty"`
AssistantMessageID string `json:"assistant_message_id,omitempty"`
ToolCalls []LLMToolCall `json:"tool_calls,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
Usage *TokenUsage `json:"usage,omitempty"`
FinishReason string `json:"finish_reason,omitempty"`
}
// References references
type References []*SearchResult
// Value implements the driver.Valuer interface, used to convert References to database values
func (c References) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements the sql.Scanner interface, used to convert database values to References
func (c *References) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, c)
}
+47
View File
@@ -0,0 +1,47 @@
package types
import (
"database/sql/driver"
"encoding/json"
)
// ChatHistoryConfig represents the chat history knowledge base configuration for a tenant.
// This config is managed via the settings UI and controls how chat messages are indexed
// and searched using a knowledge base for vector search.
//
// The KnowledgeBaseID is auto-managed: when the user enables the feature and picks an
// embedding model, the backend automatically creates (or reuses) a hidden KB.
// Users do NOT pick a KB themselves.
type ChatHistoryConfig struct {
// Enabled controls whether chat history indexing is active
Enabled bool `json:"enabled"`
// EmbeddingModelID is the ID of the embedding model used for vectorizing chat messages.
// Once messages have been indexed, the model cannot be changed (requires re-indexing).
EmbeddingModelID string `json:"embedding_model_id"`
// KnowledgeBaseID is the auto-managed hidden knowledge base for chat history.
// This is set internally when the feature is first enabled; users should not set this directly.
KnowledgeBaseID string `json:"knowledge_base_id"`
}
// Value implements the driver.Valuer interface for database serialization
func (c ChatHistoryConfig) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements the sql.Scanner interface for database deserialization
func (c *ChatHistoryConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, c)
}
// IsConfigured returns true if the chat history KB is properly configured and ready to use.
// Requires: enabled + embedding model selected + KB auto-created.
func (c *ChatHistoryConfig) IsConfigured() bool {
return c != nil && c.Enabled && c.EmbeddingModelID != "" && c.KnowledgeBaseID != ""
}
+352
View File
@@ -0,0 +1,352 @@
package types
import "maps"
// PipelineRequest holds immutable configuration set once at the request entry point.
type PipelineRequest struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Query string `json:"query,omitempty"`
MaxRounds int `json:"max_rounds"`
// Knowledge base retrieval parameters
KnowledgeBaseIDs []string `json:"knowledge_base_ids"`
KnowledgeIDs []string `json:"knowledge_ids,omitempty"`
SearchTargets SearchTargets `json:"-"`
VectorThreshold float64 `json:"vector_threshold"`
KeywordThreshold float64 `json:"keyword_threshold"`
EmbeddingTopK int `json:"embedding_top_k"`
VectorDatabase string `json:"vector_database"`
// Rerank parameters
RerankModelID string `json:"rerank_model_id"`
RerankTopK int `json:"rerank_top_k"`
RerankThreshold float64 `json:"rerank_threshold"`
// Chat model parameters
ChatModelID string `json:"chat_model_id"`
SummaryConfig SummaryConfig `json:"summary_config"`
FallbackStrategy FallbackStrategy `json:"fallback_strategy"`
FallbackResponse string `json:"fallback_response"`
FallbackPrompt string `json:"fallback_prompt"`
// CitationEnabled controls only final knowledge/web source citations. Nil
// defaults to true for requests and agents created before this option existed.
CitationEnabled *bool `json:"citation_enabled,omitempty"`
// Rewrite parameters
EnableRewrite bool `json:"enable_rewrite"`
EnableQueryExpansion bool `json:"enable_query_expansion"`
RewritePromptSystem string `json:"rewrite_prompt_system"`
RewritePromptUser string `json:"rewrite_prompt_user"`
// QueryUnderstandModelID, when set, overrides the chat model used for
// the query-understanding (rewrite + intent classification) stage only.
// Empty means fall back to ChatModelID.
QueryUnderstandModelID string `json:"query_understand_model_id,omitempty"`
// FAQ strategy
FAQPriorityEnabled bool `json:"-"`
FAQDirectAnswerThreshold float64 `json:"-"`
FAQScoreBoost float64 `json:"-"`
// DataAnalysisEnabled controls whether the in-pipeline DuckDB SQL
// data-analysis stage runs. Off by default to avoid an extra LLM call on
// every RAG request that happens to retrieve CSV/Excel chunks.
DataAnalysisEnabled bool `json:"-"`
// Image / multimodal support
Images []string `json:"-"`
VLMModelID string `json:"-"`
ChatModelSupportsVision bool `json:"-"`
// File attachments support
Attachments MessageAttachments `json:"-"`
// IntentPromptOverrides holds agent-level intent prompt overrides for the
// query-understanding stage. Empty values fall back to tenant/global defaults.
IntentPromptOverrides map[string]string `json:"-"`
// Misc request-scoped config
TenantID uint64 `json:"-"`
WebSearchEnabled bool `json:"-"`
WebSearchProviderID string `json:"-"` // Resolved from agent config or tenant default
WebSearchMaxResults int `json:"-"` // Resolved from agent config or tenant default
WebFetchEnabled bool `json:"-"` // Auto-fetch full page content for web search results after rerank
WebFetchTopN int `json:"-"` // Max pages to fetch (default 3)
Language string `json:"-"`
}
// CitationsEnabled returns the effective citation setting for this request.
func (c *PipelineRequest) CitationsEnabled() bool {
return c == nil || c.CitationEnabled == nil || *c.CitationEnabled
}
// QueryIntent represents the classified intent of a user query.
type QueryIntent string
const (
IntentKBSearch QueryIntent = "kb_search"
IntentWebSearch QueryIntent = "web_search"
IntentGreeting QueryIntent = "greeting"
IntentChitchat QueryIntent = "chitchat"
IntentFollowUp QueryIntent = "follow_up"
IntentImageOnly QueryIntent = "image_only"
IntentDocOnly QueryIntent = "doc_only"
IntentSummarize QueryIntent = "summarize"
IntentClarification QueryIntent = "clarification"
)
// NeedsKBRetrieval returns true when the intent requires knowledge base search.
// The zero value (empty string) is treated as needing retrieval for safety.
// Note: IntentWebSearch is NOT included — use ChatManage.NeedsRetrieval()
// which also considers the WebSearchEnabled flag.
func (i QueryIntent) NeedsKBRetrieval() bool {
switch i {
case IntentKBSearch, IntentClarification, IntentSummarize, "":
return true
default:
return false
}
}
// PipelineState holds mutable intermediate data that plugins read and write
// as the pipeline progresses.
type PipelineState struct {
RewriteQuery string `json:"rewrite_query,omitempty"`
Intent QueryIntent `json:"intent,omitempty"`
History []*History `json:"history,omitempty"`
SearchResult []*SearchResult `json:"-"`
RerankResult []*SearchResult `json:"-"`
MergeResult []*SearchResult `json:"-"`
Entity []string `json:"-"`
EntityKBIDs []string `json:"-"`
EntityKnowledge map[string]string `json:"-"`
GraphResult *GraphData `json:"-"`
UserContent string `json:"-"`
RenderedContexts string `json:"-"`
ChatResponse *ChatResponse `json:"-"`
ImageDescription string `json:"-"`
QuotedContext string `json:"-"` // Quoted message text, injected at LLM prompt stage
SystemPromptOverride string `json:"-"`
// MemoryPrompt is the long-term memory envelope appended to the system
// prompt for this turn, empty when memory is off or nothing matched.
MemoryPrompt string `json:"-"`
// UsedMemories mirrors MemoryPrompt in structured form so the answer can
// tell the user which memories it saw.
UsedMemories UsedMemories `json:"-"`
}
// PipelineContext holds runtime context for the current pipeline execution.
type PipelineContext struct {
EventBus EventBusInterface `json:"-"`
MessageID string `json:"-"`
UserMessageID string `json:"-"`
}
// ChatManage represents the full configuration, state and runtime context
// for a chat pipeline execution. It embeds PipelineRequest (immutable config),
// PipelineState (mutable intermediate data), and PipelineContext (runtime handles).
type ChatManage struct {
PipelineRequest
PipelineState
PipelineContext
}
// NeedsRetrieval returns true when the current pipeline execution should
// run the retrieval stages (search, rerank, merge, etc.).
// For IntentWebSearch, retrieval is only needed if web search is enabled;
// for all other intents it delegates to QueryIntent.NeedsKBRetrieval().
func (c *ChatManage) NeedsRetrieval() bool {
if c.Intent == IntentWebSearch {
return c.WebSearchEnabled
}
return c.Intent.NeedsKBRetrieval()
}
// Clone creates a deep copy of the ChatManage object.
// PipelineContext fields (EventBus, MessageID, etc.) are NOT copied because they
// are per-execution handles that should not be shared across clones.
func (c *ChatManage) Clone() *ChatManage {
knowledgeBaseIDs := make([]string, len(c.KnowledgeBaseIDs))
copy(knowledgeBaseIDs, c.KnowledgeBaseIDs)
knowledgeIDs := make([]string, len(c.KnowledgeIDs))
copy(knowledgeIDs, c.KnowledgeIDs)
searchTargets := make(SearchTargets, len(c.SearchTargets))
for i, t := range c.SearchTargets {
if t != nil {
kidsCopy := make([]string, len(t.KnowledgeIDs))
copy(kidsCopy, t.KnowledgeIDs)
tagIDsCopy := make([]string, len(t.TagIDs))
copy(tagIDsCopy, t.TagIDs)
scopeTagIDsCopy := make([]string, len(t.ScopeTagIDs))
copy(scopeTagIDsCopy, t.ScopeTagIDs)
searchTargets[i] = &SearchTarget{
Type: t.Type,
KnowledgeBaseID: t.KnowledgeBaseID,
TenantID: t.TenantID,
KnowledgeIDs: kidsCopy,
TagIDs: tagIDsCopy,
ScopeTagIDs: scopeTagIDsCopy,
DisableRecallThresholds: t.DisableRecallThresholds,
}
}
}
// Deep copy Entity using in search entity plugin
entity := make([]string, len(c.Entity))
copy(entity, c.Entity)
entityKBIDs := make([]string, len(c.EntityKBIDs))
copy(entityKBIDs, c.EntityKBIDs)
entityKnowledge := make(map[string]string)
maps.Copy(entityKnowledge, c.EntityKnowledge)
return &ChatManage{
PipelineRequest: PipelineRequest{
Query: c.Query,
SessionID: c.SessionID,
UserID: c.UserID,
MaxRounds: c.MaxRounds,
KnowledgeBaseIDs: knowledgeBaseIDs,
KnowledgeIDs: knowledgeIDs,
SearchTargets: searchTargets,
VectorThreshold: c.VectorThreshold,
KeywordThreshold: c.KeywordThreshold,
EmbeddingTopK: c.EmbeddingTopK,
VectorDatabase: c.VectorDatabase,
RerankModelID: c.RerankModelID,
RerankTopK: c.RerankTopK,
RerankThreshold: c.RerankThreshold,
ChatModelID: c.ChatModelID,
SummaryConfig: c.SummaryConfig,
FallbackStrategy: c.FallbackStrategy,
FallbackResponse: c.FallbackResponse,
FallbackPrompt: c.FallbackPrompt,
CitationEnabled: c.CitationEnabled,
EnableRewrite: c.EnableRewrite,
EnableQueryExpansion: c.EnableQueryExpansion,
RewritePromptSystem: c.RewritePromptSystem,
RewritePromptUser: c.RewritePromptUser,
QueryUnderstandModelID: c.QueryUnderstandModelID,
FAQPriorityEnabled: c.FAQPriorityEnabled,
FAQDirectAnswerThreshold: c.FAQDirectAnswerThreshold,
FAQScoreBoost: c.FAQScoreBoost,
DataAnalysisEnabled: c.DataAnalysisEnabled,
Images: append([]string(nil), c.Images...),
VLMModelID: c.VLMModelID,
ChatModelSupportsVision: c.ChatModelSupportsVision,
Attachments: append(MessageAttachments(nil), c.Attachments...),
TenantID: c.TenantID,
WebSearchEnabled: c.WebSearchEnabled,
WebSearchProviderID: c.WebSearchProviderID,
WebSearchMaxResults: c.WebSearchMaxResults,
WebFetchEnabled: c.WebFetchEnabled,
WebFetchTopN: c.WebFetchTopN,
Language: c.Language,
IntentPromptOverrides: maps.Clone(c.IntentPromptOverrides),
},
PipelineState: PipelineState{
RewriteQuery: c.RewriteQuery,
Intent: c.Intent,
ImageDescription: c.ImageDescription,
QuotedContext: c.QuotedContext,
SystemPromptOverride: c.SystemPromptOverride,
MemoryPrompt: c.MemoryPrompt,
UsedMemories: append(UsedMemories(nil), c.UsedMemories...),
RenderedContexts: c.RenderedContexts,
Entity: entity,
EntityKBIDs: entityKBIDs,
EntityKnowledge: entityKnowledge,
},
}
}
// EventType represents different stages in the RAG (Retrieval Augmented Generation) pipeline
type EventType string
const (
LOAD_HISTORY EventType = "load_history"
MEMORY_RECALL EventType = "memory_recall"
QUERY_UNDERSTAND EventType = "query_understand"
CHUNK_SEARCH EventType = "chunk_search"
CHUNK_SEARCH_PARALLEL EventType = "chunk_search_parallel"
ENTITY_SEARCH EventType = "entity_search"
CHUNK_RERANK EventType = "chunk_rerank"
WEB_FETCH EventType = "web_fetch"
CHUNK_MERGE EventType = "chunk_merge"
DATA_ANALYSIS EventType = "data_analysis"
INTO_CHAT_MESSAGE EventType = "into_chat_message"
CHAT_COMPLETION EventType = "chat_completion"
CHAT_COMPLETION_STREAM EventType = "chat_completion_stream"
FILTER_TOP_K EventType = "filter_top_k"
)
// PipelineBuilder dynamically assembles a pipeline as an ordered list of EventTypes.
type PipelineBuilder struct {
stages []EventType
}
// NewPipelineBuilder returns an empty builder.
func NewPipelineBuilder() *PipelineBuilder {
return &PipelineBuilder{}
}
// Add appends one or more stages unconditionally.
func (b *PipelineBuilder) Add(stages ...EventType) *PipelineBuilder {
b.stages = append(b.stages, stages...)
return b
}
// AddIf appends stages only when the condition is true.
func (b *PipelineBuilder) AddIf(cond bool, stages ...EventType) *PipelineBuilder {
if cond {
b.stages = append(b.stages, stages...)
}
return b
}
// Build returns the final event list. The builder must not be reused.
func (b *PipelineBuilder) Build() []EventType {
out := make([]EventType, len(b.stages))
copy(out, b.stages)
return out
}
// Pipeline defines the sequence of events for different chat modes.
// Kept as a convenience lookup for callers that don't need dynamic composition.
var Pipeline = map[string][]EventType{
"chat": {
CHAT_COMPLETION,
},
"chat_stream": {
CHAT_COMPLETION_STREAM,
},
"chat_history_stream": {
LOAD_HISTORY,
CHAT_COMPLETION_STREAM,
},
"rag": {
CHUNK_SEARCH,
CHUNK_RERANK,
CHUNK_MERGE,
INTO_CHAT_MESSAGE,
CHAT_COMPLETION,
},
"rag_stream": {
LOAD_HISTORY,
QUERY_UNDERSTAND,
CHUNK_SEARCH_PARALLEL,
CHUNK_RERANK,
CHUNK_MERGE,
FILTER_TOP_K,
DATA_ANALYSIS,
INTO_CHAT_MESSAGE,
CHAT_COMPLETION_STREAM,
},
}
// Pipline is a deprecated alias for Pipeline (kept for backward compatibility).
var Pipline = Pipeline
+242
View File
@@ -0,0 +1,242 @@
// Package types defines data structures and types used throughout the system
// These types are shared across different service modules to ensure data consistency
package types
import (
"strings"
"time"
"gorm.io/gorm"
)
// ChunkType 定义了不同类型的 Chunk
type ChunkType = string
const (
// ChunkTypeText 表示普通的文本 Chunk
ChunkTypeText ChunkType = "text"
// ChunkTypeParentText 表示父子分块策略中的父文本 Chunk(仅用于上下文,不参与向量索引)
ChunkTypeParentText ChunkType = "parent_text"
// ChunkTypeImageOCR 表示图片 OCR 文本的 Chunk
ChunkTypeImageOCR ChunkType = "image_ocr"
// ChunkTypeImageCaption 表示图片描述的 Chunk
ChunkTypeImageCaption ChunkType = "image_caption"
// ChunkTypeSummary 表示摘要类型的 Chunk
ChunkTypeSummary = "summary"
// ChunkTypeEntity 表示实体类型的 Chunk
ChunkTypeEntity ChunkType = "entity"
// ChunkTypeRelationship 表示关系类型的 Chunk
ChunkTypeRelationship ChunkType = "relationship"
// ChunkTypeFAQ 表示 FAQ 条目 Chunk
ChunkTypeFAQ ChunkType = "faq"
// ChunkTypeWebSearch 表示 Web 搜索结果的 Chunk
ChunkTypeWebSearch ChunkType = "web_search"
// ChunkTypeTableSummary 表示数据表摘要的 Chunk
ChunkTypeTableSummary ChunkType = "table_summary"
// ChunkTypeTableColumn 表示数据表列描述的 Chunk
ChunkTypeTableColumn ChunkType = "table_column"
// ChunkTypeWikiPage 表示 Wiki 页面同步的 Chunk,用于将 wiki 页面接入现有检索管线
ChunkTypeWikiPage ChunkType = "wiki_page"
)
// ChunkStatus 定义了不同状态的 Chunk
type ChunkStatus int
const (
ChunkStatusDefault ChunkStatus = 0
// ChunkStatusStored 表示已存储的 Chunk
ChunkStatusStored ChunkStatus = 1
// ChunkStatusIndexed 表示已索引的 Chunk
ChunkStatusIndexed ChunkStatus = 2
)
// ChunkFlags 定义 Chunk 的标志位,用于管理多个布尔状态
type ChunkFlags int
const (
// ChunkFlagRecommended 表示可推荐状态(1 << 0 = 1)
// 当设置此标志时,该 Chunk 可以被推荐给用户
ChunkFlagRecommended ChunkFlags = 1 << 0
// 未来可扩展更多标志位:
// ChunkFlagPinned ChunkFlags = 1 << 1 // 置顶
// ChunkFlagHot ChunkFlags = 1 << 2 // 热门
)
// HasFlag 检查是否设置了指定标志
func (f ChunkFlags) HasFlag(flag ChunkFlags) bool {
return f&flag != 0
}
// SetFlag 设置指定标志
func (f ChunkFlags) SetFlag(flag ChunkFlags) ChunkFlags {
return f | flag
}
// ClearFlag 清除指定标志
func (f ChunkFlags) ClearFlag(flag ChunkFlags) ChunkFlags {
return f &^ flag
}
// ToggleFlag 切换指定标志
func (f ChunkFlags) ToggleFlag(flag ChunkFlags) ChunkFlags {
return f ^ flag
}
// ImageInfo 表示与 Chunk 关联的图片信息
type ImageInfo struct {
// 图片URL(COS)
URL string `json:"url" gorm:"type:text"`
// 原始图片URL
OriginalURL string `json:"original_url" gorm:"type:text"`
// 图片在文本中的开始位置
StartPos int `json:"start_pos"`
// 图片在文本中的结束位置
EndPos int `json:"end_pos"`
// 图片描述
Caption string `json:"caption"`
// 图片OCR文本
OCRText string `json:"ocr_text"`
}
// VideoInfo 表示与 Chunk 关联的视频信息
type VideoInfo struct {
// 视频URL
URL string `json:"url" gorm:"type:text"`
}
// Chunk represents a document chunk
// Chunks are meaningful text segments extracted from original documents
// and are the basic units of knowledge base retrieval
// Each chunk contains a portion of the original content
// and maintains its positional relationship with the original text
// Chunks can be independently embedded as vectors and retrieved, supporting precise content localization
type Chunk struct {
// Unique identifier of the chunk, using UUID format
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// SeqID is an auto-increment integer ID for external API usage (FAQ entries)
SeqID int64 `json:"seq_id" gorm:"type:bigint;uniqueIndex;autoIncrement"`
// Tenant ID, used for multi-tenant isolation
TenantID uint64 `json:"tenant_id"`
// ID of the parent knowledge, associated with the Knowledge model
KnowledgeID string `json:"knowledge_id"`
// ID of the knowledge base, for quick location
KnowledgeBaseID string `json:"knowledge_base_id"`
// Optional tag ID for categorization within a knowledge base (used for FAQ)
TagID string `json:"tag_id" gorm:"type:varchar(36);index"`
// Actual text content of the chunk
Content string `json:"content"`
// SourceContent is the immutable parser output. Legacy rows are lazily
// backfilled from Content on the first manual edit.
SourceContent string `json:"-"`
// ContentRevision is incremented for every user edit or rollback.
ContentRevision int `json:"content_revision" gorm:"not null;default:0"`
// IndexStatus reports whether the current content is reflected in the
// retrieval stores: ready | processing | failed.
IndexStatus string `json:"index_status" gorm:"type:varchar(16);not null;default:'ready'"`
// LastEditorID records the actor that produced the current revision.
LastEditorID string `json:"last_editor_id" gorm:"type:varchar(64);not null;default:''"`
// Index position of the chunk in the original document
ChunkIndex int `json:"chunk_index"`
// Whether the chunk is enabled, can be used to temporarily disable certain chunks
IsEnabled bool `json:"is_enabled" gorm:"default:true"`
// Flags 存储多个布尔状态的位标志(如推荐状态等)
// 默认值为 ChunkFlagRecommended (1),表示默认可推荐
Flags ChunkFlags `json:"flags" gorm:"default:1"`
// Status of the chunk
Status int `json:"status" gorm:"default:0"`
// Starting character position in the original text
StartAt int `json:"start_at"`
// Ending character position in the original text
EndAt int `json:"end_at"`
// Previous chunk ID
PreChunkID string `json:"pre_chunk_id"`
// Next chunk ID
NextChunkID string `json:"next_chunk_id"`
// Chunk 类型,用于区分不同类型的 Chunk
ChunkType ChunkType `json:"chunk_type" gorm:"type:varchar(20);default:'text'"`
// 父 Chunk ID,用于关联图片 Chunk 和原始文本 Chunk
ParentChunkID string `json:"parent_chunk_id" gorm:"type:varchar(36);index"`
// 关系 Chunk ID,用于关联关系 Chunk 和原始文本 Chunk
RelationChunks JSON `json:"relation_chunks" gorm:"type:json"`
// 间接关系 Chunk ID,用于关联间接关系 Chunk 和原始文本 Chunk
IndirectRelationChunks JSON `json:"indirect_relation_chunks" gorm:"type:json"`
// Metadata 存储 chunk 级别的扩展信息,例如 FAQ 元数据
Metadata JSON `json:"metadata" gorm:"type:json"`
// ContentHash 存储内容的 hash 值,用于快速匹配(主要用于 FAQ)
ContentHash string `json:"content_hash" gorm:"type:varchar(64);index"`
// 图片信息,存储为 JSON
ImageInfo string `json:"image_info" gorm:"type:text"`
// Chunk creation time
CreatedAt time.Time `json:"created_at"`
// Chunk last update time
UpdatedAt time.Time `json:"updated_at"`
// Soft delete marker, supports data recovery
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
// ContextHeader is a Markdown heading breadcrumb prepended when indexing.
// It is persisted so a later content edit can rebuild the same index input.
ContextHeader string `json:"-" gorm:"type:text"`
}
// ChunkRevision is an immutable snapshot of a superseded chunk revision.
// The current content lives on Chunk; this table stores prior versions.
type ChunkRevision struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"index"`
KnowledgeBaseID string `json:"knowledge_base_id" gorm:"type:varchar(36);index"`
KnowledgeID string `json:"knowledge_id" gorm:"type:varchar(36);index"`
ChunkID string `json:"chunk_id" gorm:"type:varchar(36);uniqueIndex:idx_chunk_revision"`
Revision int `json:"revision" gorm:"uniqueIndex:idx_chunk_revision"`
Content string `json:"content" gorm:"type:text"`
IsEnabled bool `json:"is_enabled"`
EditorID string `json:"editor_id" gorm:"type:varchar(64)"`
EditSource string `json:"edit_source" gorm:"type:varchar(16)"`
EditedAt time.Time `json:"edited_at"`
CreatedAt time.Time `json:"created_at"`
}
// EmbeddingContent returns the chunk content with ContextHeader prepended
// when set. Use this where the embedding model needs section context that
// isn't part of the literal Content. Surrounding whitespace on Content is
// trimmed so leading/trailing newlines from boundary slicing don't dilute
// the embedded vector.
func (c *Chunk) EmbeddingContent() string {
if c == nil {
return ""
}
body := strings.TrimSpace(c.Content)
if c.ContextHeader == "" {
return body
}
return c.ContextHeader + "\n\n" + body
}
// AssignChunkSeqIDs assigns sequential SeqIDs to a batch of chunks that have SeqID == 0.
// Must be called before CreateInBatches for SQLite compatibility.
func AssignChunkSeqIDs(tx *gorm.DB, chunks []*Chunk) error {
needAssign := false
for _, c := range chunks {
if c.SeqID == 0 {
needAssign = true
break
}
}
if !needAssign {
return nil
}
var maxSeqID *int64
if err := tx.Unscoped().Model(&Chunk{}).Select("MAX(seq_id)").Scan(&maxSeqID).Error; err != nil {
return err
}
next := int64(1)
if maxSeqID != nil {
next = *maxSeqID + 1
}
for _, c := range chunks {
if c.SeqID == 0 {
c.SeqID = next
next++
}
}
return nil
}
@@ -0,0 +1,35 @@
package types
import (
"encoding/json"
"strings"
"testing"
)
func TestChunkContextHeaderHiddenInJSON(t *testing.T) {
c := Chunk{
ID: "test",
Content: "body",
ContextHeader: "# Heading",
}
b, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "Heading") || strings.Contains(string(b), "context_header") {
t.Errorf("ContextHeader leaked into JSON: %s", string(b))
}
}
func TestParsedChunkEmbeddingContent(t *testing.T) {
pc := ParsedChunk{Content: "body", ContextHeader: "# H"}
got := pc.EmbeddingContent()
want := "# H\n\nbody"
if got != want {
t.Errorf("EmbeddingContent mismatch: got %q, want %q", got, want)
}
pc2 := ParsedChunk{Content: "only body"}
if pc2.EmbeddingContent() != "only body" {
t.Errorf("EmbeddingContent without header should equal Content")
}
}
+4
View File
@@ -0,0 +1,4 @@
package types
// CleanupFunc represents the resource cleanup function
type CleanupFunc func() error
+453
View File
@@ -0,0 +1,453 @@
package types
import "strings"
// WebSearchConfigForResponse returns a copy safe for HTTP responses.
// When maskSecrets is true, api_key is omitted and a configured proxy_url
// is replaced with RedactedSecretPlaceholder.
func WebSearchConfigForResponse(cfg *WebSearchConfig, maskSecrets bool) *WebSearchConfig {
if cfg == nil {
return nil
}
out := *EffectiveWebSearchConfig(cfg)
if !maskSecrets {
return &out
}
out.APIKey = ""
if strings.TrimSpace(out.ProxyURL) != "" {
out.ProxyURL = RedactedSecretPlaceholder
}
return &out
}
// ParserEngineConfigForResponse returns a copy with secret fields redacted
// when maskSecrets is true.
func ParserEngineConfigForResponse(cfg *ParserEngineConfig, maskSecrets bool) *ParserEngineConfig {
if cfg == nil {
return nil
}
out := *cfg
if !maskSecrets {
return &out
}
if out.MinerUAPIKey != "" {
out.MinerUAPIKey = RedactedSecretPlaceholder
}
if out.PaddleOCRVLCloudToken != "" {
out.PaddleOCRVLCloudToken = RedactedSecretPlaceholder
}
return &out
}
// StorageEngineConfigForResponse returns a copy with provider secret fields
// redacted when maskSecrets is true.
func StorageEngineConfigForResponse(cfg *StorageEngineConfig, maskSecrets bool) *StorageEngineConfig {
if cfg == nil {
return nil
}
out := *cfg
if !maskSecrets {
return &out
}
if out.MinIO != nil {
minio := *out.MinIO
if minio.AccessKeyID != "" {
minio.AccessKeyID = RedactedSecretPlaceholder
}
if minio.SecretAccessKey != "" {
minio.SecretAccessKey = RedactedSecretPlaceholder
}
out.MinIO = &minio
}
if out.COS != nil {
cos := *out.COS
if cos.SecretID != "" {
cos.SecretID = RedactedSecretPlaceholder
}
if cos.SecretKey != "" {
cos.SecretKey = RedactedSecretPlaceholder
}
out.COS = &cos
}
if out.TOS != nil {
tos := *out.TOS
if tos.AccessKey != "" {
tos.AccessKey = RedactedSecretPlaceholder
}
if tos.SecretKey != "" {
tos.SecretKey = RedactedSecretPlaceholder
}
out.TOS = &tos
}
if out.S3 != nil {
s3 := *out.S3
if s3.AccessKey != "" {
s3.AccessKey = RedactedSecretPlaceholder
}
if s3.SecretKey != "" {
s3.SecretKey = RedactedSecretPlaceholder
}
out.S3 = &s3
}
if out.OSS != nil {
oss := *out.OSS
if oss.AccessKey != "" {
oss.AccessKey = RedactedSecretPlaceholder
}
if oss.SecretKey != "" {
oss.SecretKey = RedactedSecretPlaceholder
}
out.OSS = &oss
}
if out.KS3 != nil {
ks3 := *out.KS3
if ks3.AccessKey != "" {
ks3.AccessKey = RedactedSecretPlaceholder
}
if ks3.SecretKey != "" {
ks3.SecretKey = RedactedSecretPlaceholder
}
out.KS3 = &ks3
}
if out.OBS != nil {
obs := *out.OBS
if obs.AccessKey != "" {
obs.AccessKey = RedactedSecretPlaceholder
}
if obs.SecretKey != "" {
obs.SecretKey = RedactedSecretPlaceholder
}
out.OBS = &obs
}
return &out
}
// CredentialsConfigForResponse returns a copy with app_secret redacted when
// maskSecrets is true.
func CredentialsConfigForResponse(cfg *CredentialsConfig, maskSecrets bool) *CredentialsConfig {
if cfg == nil {
return nil
}
out := *cfg
if !maskSecrets {
return &out
}
if out.WeKnoraCloud != nil {
cloud := *out.WeKnoraCloud
if cloud.AppSecret != "" {
cloud.AppSecret = RedactedSecretPlaceholder
}
out.WeKnoraCloud = &cloud
}
return &out
}
// MergeWebSearchConfigForUpdate applies preserve semantics to secret fields on
// tenant KV PUT.
func MergeWebSearchConfigForUpdate(incoming, existing *WebSearchConfig) *WebSearchConfig {
out := *EffectiveWebSearchConfig(incoming)
var prev WebSearchConfig
if existing != nil {
prev = *EffectiveWebSearchConfig(existing)
}
out.APIKey = PreserveIfRedacted(out.APIKey, prev.APIKey)
out.ProxyURL = PreserveIfRedacted(out.ProxyURL, prev.ProxyURL)
return &out
}
// MergeParserEngineConfigForUpdate applies preserve semantics to secret fields
// on tenant KV PUT.
func MergeParserEngineConfigForUpdate(incoming, existing *ParserEngineConfig) *ParserEngineConfig {
if incoming == nil {
return nil
}
out := *incoming
var prev ParserEngineConfig
if existing != nil {
prev = *existing
}
out.MinerUAPIKey = PreserveIfRedacted(out.MinerUAPIKey, prev.MinerUAPIKey)
out.PaddleOCRVLCloudToken = PreserveIfRedacted(out.PaddleOCRVLCloudToken, prev.PaddleOCRVLCloudToken)
// Chat attachment parser rules are configured per agent; preserve any legacy
// tenant-level rules when the settings UI omits this field on engine updates.
if incoming.ChatParserEngineRules == nil && existing != nil {
out.ChatParserEngineRules = existing.ChatParserEngineRules
}
return &out
}
// MergeStorageEngineConfigForUpdate applies preserve semantics to provider
// secret fields on tenant KV PUT.
func MergeStorageEngineConfigForUpdate(incoming, existing *StorageEngineConfig) *StorageEngineConfig {
if incoming == nil {
return nil
}
out := *incoming
if out.MinIO != nil {
minio := *out.MinIO
var prev MinIOEngineConfig
if existing != nil && existing.MinIO != nil {
prev = *existing.MinIO
}
minio.AccessKeyID = PreserveIfRedacted(minio.AccessKeyID, prev.AccessKeyID)
minio.SecretAccessKey = PreserveIfRedacted(minio.SecretAccessKey, prev.SecretAccessKey)
out.MinIO = &minio
}
if out.COS != nil {
cos := *out.COS
var prev COSEngineConfig
if existing != nil && existing.COS != nil {
prev = *existing.COS
}
cos.SecretID = PreserveIfRedacted(cos.SecretID, prev.SecretID)
cos.SecretKey = PreserveIfRedacted(cos.SecretKey, prev.SecretKey)
out.COS = &cos
}
if out.TOS != nil {
tos := *out.TOS
var prev TOSEngineConfig
if existing != nil && existing.TOS != nil {
prev = *existing.TOS
}
tos.AccessKey = PreserveIfRedacted(tos.AccessKey, prev.AccessKey)
tos.SecretKey = PreserveIfRedacted(tos.SecretKey, prev.SecretKey)
out.TOS = &tos
}
if out.S3 != nil {
s3 := *out.S3
var prev S3EngineConfig
if existing != nil && existing.S3 != nil {
prev = *existing.S3
}
// Empty S3 credentials intentionally switch authentication to the AWS
// default credential chain. Only the response placeholder means "keep
// the stored value"; treating empty as preserve makes it impossible to
// migrate an existing static AK/SK configuration to IAM roles.
if s3.AccessKey == RedactedSecretPlaceholder {
s3.AccessKey = prev.AccessKey
}
if s3.SecretKey == RedactedSecretPlaceholder {
s3.SecretKey = prev.SecretKey
}
out.S3 = &s3
}
if out.OSS != nil {
oss := *out.OSS
var prev OSSEngineConfig
if existing != nil && existing.OSS != nil {
prev = *existing.OSS
}
oss.AccessKey = PreserveIfRedacted(oss.AccessKey, prev.AccessKey)
oss.SecretKey = PreserveIfRedacted(oss.SecretKey, prev.SecretKey)
out.OSS = &oss
}
if out.KS3 != nil {
ks3 := *out.KS3
var prev KS3EngineConfig
if existing != nil && existing.KS3 != nil {
prev = *existing.KS3
}
ks3.AccessKey = PreserveIfRedacted(ks3.AccessKey, prev.AccessKey)
ks3.SecretKey = PreserveIfRedacted(ks3.SecretKey, prev.SecretKey)
out.KS3 = &ks3
}
if out.OBS != nil {
obs := *out.OBS
var prev OBSEngineConfig
if existing != nil && existing.OBS != nil {
prev = *existing.OBS
}
obs.AccessKey = PreserveIfRedacted(obs.AccessKey, prev.AccessKey)
obs.SecretKey = PreserveIfRedacted(obs.SecretKey, prev.SecretKey)
out.OBS = &obs
}
return &out
}
// SandboxConfigForResponse returns a copy of cfg safe to serialize into an API
// response. When maskSecrets is true every secret-bearing field is replaced
// with RedactedSecretPlaceholder. Unset secrets stay empty so the UI can tell
// "configured" apart from "not configured".
func SandboxConfigForResponse(cfg *TenantSandboxConfig, maskSecrets bool) *TenantSandboxConfig {
if cfg == nil {
return nil
}
out := *cfg
if !maskSecrets {
return &out
}
if out.Cube != nil {
cube := *out.Cube
if cube.APIKey != "" {
cube.APIKey = RedactedSecretPlaceholder
}
out.Cube = &cube
}
if out.E2B != nil {
e2b := *out.E2B
if e2b.APIKey != "" {
e2b.APIKey = RedactedSecretPlaceholder
}
out.E2B = &e2b
}
// EnvVars values are encrypted at rest and may hold credentials, so they
// are masked as a class rather than by name.
if len(out.EnvVars) > 0 {
envVars := make(map[string]string, len(out.EnvVars))
for name, value := range out.EnvVars {
if value != "" {
value = RedactedSecretPlaceholder
}
envVars[name] = value
}
out.EnvVars = envVars
}
// Injected headers carry credentials, so they are masked as a class the
// same way EnvVars values are.
if out.Network != nil {
out.Network = out.Network.CloneWithSecrets(func(value string) string {
if value == "" {
return ""
}
return RedactedSecretPlaceholder
})
}
return &out
}
// MergeSandboxConfigForUpdate resolves redacted placeholders in incoming
// against the currently stored config, so a client that never received the
// real secret can submit the rest of the form without wiping it. Pointers
// the editor does not own (SkillImage, VolumeMount) are kept from existing.
func MergeSandboxConfigForUpdate(incoming, existing *TenantSandboxConfig) *TenantSandboxConfig {
if incoming == nil {
return nil
}
out := *incoming
if out.Cube != nil {
cube := *out.Cube
var prev CubeSandboxConfig
if existing != nil && existing.Cube != nil {
prev = *existing.Cube
}
cube.APIKey = PreserveIfRedacted(cube.APIKey, prev.APIKey)
out.Cube = &cube
}
if out.E2B != nil {
e2b := *out.E2B
var prev E2BSandboxConfig
if existing != nil && existing.E2B != nil {
prev = *existing.E2B
}
e2b.APIKey = PreserveIfRedacted(e2b.APIKey, prev.APIKey)
out.E2B = &e2b
}
// Only keys present in incoming survive: deleting a row in the UI must
// actually remove the variable rather than silently restore it.
if len(out.EnvVars) > 0 {
envVars := make(map[string]string, len(out.EnvVars))
for name, value := range out.EnvVars {
prev := ""
if existing != nil {
prev = existing.EnvVars[name]
}
envVars[name] = PreserveIfRedacted(value, prev)
}
out.EnvVars = envVars
}
// Network is an editor-owned field, so incoming decides its shape; only
// the credential values fall back to what is stored. Unlike SkillImage,
// omitting it clears it — the admin deleting every rule must stick.
if out.Network != nil {
var prev *SandboxNetworkPolicy
if existing != nil {
prev = existing.Network
}
out.Network = mergeNetworkPolicyForUpdate(out.Network, prev)
}
// SkillImage and VolumeMount are owned by the install / volume paths, not
// by the sandbox settings form. The editor rebuilds the payload without
// either field, so copying incoming as-is would wipe a live snapshot on
// every runtime save and leave sessions booting the base template while the
// skill rows still claim ready. Reading them from the stored row instead —
// and clearing when there is no stored row — also means a crafted PUT can
// neither plant nor wipe a pointer.
out.SkillImage = nil
out.VolumeMount = nil
if existing != nil {
if existing.SkillImage != nil {
image := *existing.SkillImage
out.SkillImage = &image
}
if existing.VolumeMount != nil {
mount := *existing.VolumeMount
out.VolumeMount = &mount
}
// The runtime form omits skill_rollout. An empty incoming value must
// not reset a saved "new_session" choice; the skills panel sends the
// explicit next_turn token when the admin switches back.
if strings.TrimSpace(out.SkillRollout) == "" {
out.SkillRollout = existing.SkillRollout
}
}
return &out
}
// mergeNetworkPolicyForUpdate resolves redacted injected-header secrets in
// incoming against existing. Rules are matched by the identity the admin sees:
// a Cube inject by (rule name, header name), an E2B header by (host, header
// name). A renamed rule therefore loses its stored secret, which is correct —
// there is no way to tell a rename from a replacement.
func mergeNetworkPolicyForUpdate(
incoming, existing *SandboxNetworkPolicy,
) *SandboxNetworkPolicy {
if incoming == nil {
return nil
}
out := incoming.CloneWithSecrets(func(value string) string { return value })
// Inbound is always credential-required. Accept the wire field so old
// clients still decode, then drop it so it cannot persist or reopen.
out.AllowPublicInbound = false
if existing == nil {
return out
}
storedCube := make(map[string]string)
for _, rule := range existing.CubeRules {
for _, inject := range rule.Inject {
storedCube[networkSecretKey(rule.Name, inject.Header)] = inject.Secret
}
}
for i, rule := range out.CubeRules {
for j, inject := range rule.Inject {
prev := storedCube[networkSecretKey(rule.Name, inject.Header)]
out.CubeRules[i].Inject[j].Secret = PreserveIfRedacted(inject.Secret, prev)
}
}
storedE2B := make(map[string]string)
for _, rule := range existing.E2BHostRules {
for name, value := range rule.Headers {
storedE2B[networkSecretKey(rule.Host, name)] = value
}
}
for i, rule := range out.E2BHostRules {
for name, value := range rule.Headers {
prev := storedE2B[networkSecretKey(rule.Host, name)]
out.E2BHostRules[i].Headers[name] = PreserveIfRedacted(value, prev)
}
}
return out
}
// networkSecretKey is the identity the merge uses for one injected header.
// Parent (rule name / host) and child (header name) are trimmed and folded so
// a whitespace or HTTP-header case change is not treated as a rename that
// drops the stored secret.
func networkSecretKey(parent, child string) string {
return strings.ToLower(strings.TrimSpace(parent)) + "\x00" + strings.ToLower(strings.TrimSpace(child))
}
@@ -0,0 +1,345 @@
package types
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestSandboxConfigValueEncryptsInjectedHeaderSecrets(t *testing.T) {
t.Setenv("SYSTEM_AES_KEY", "0123456789abcdef0123456789abcdef")
cfg := &TenantSandboxConfig{
SandboxType: "cube",
Network: &SandboxNetworkPolicy{
CubeRules: []CubeEgressRule{{
Name: "allow-api",
Host: "api.example.com",
Inject: []CubeHeaderInject{{Header: "Authorization", Secret: "cube-secret"}},
}},
E2BHostRules: []E2BHostRule{{
Host: "api.example.com",
Headers: map[string]string{"X-Key": "e2b-secret"},
}},
},
}
raw, err := cfg.Value()
require.NoError(t, err)
encoded := string(raw.([]byte))
require.NotContains(t, encoded, "cube-secret")
require.NotContains(t, encoded, "e2b-secret")
// Header names stay readable, same rationale as EnvVars keys.
require.Contains(t, encoded, "Authorization")
require.Contains(t, encoded, "X-Key")
var loaded TenantSandboxConfig
require.NoError(t, loaded.Scan(raw.([]byte)))
require.Equal(t, "cube-secret", loaded.Network.CubeRules[0].Inject[0].Secret)
require.Equal(t, "e2b-secret", loaded.Network.E2BHostRules[0].Headers["X-Key"])
}
func TestSandboxConfigForResponseMasksSecrets(t *testing.T) {
cfg := &TenantSandboxConfig{
SandboxType: "e2b",
E2B: &E2BSandboxConfig{APIKey: "e2b-secret", APIURL: "https://api.e2b.dev"},
Cube: &CubeSandboxConfig{APIKey: "cube-secret", APIURL: "http://cube"},
EnvVars: map[string]string{"HF_TOKEN": "hf-secret"},
}
out := SandboxConfigForResponse(cfg, true)
require.Equal(t, RedactedSecretPlaceholder, out.E2B.APIKey)
require.Equal(t, RedactedSecretPlaceholder, out.Cube.APIKey)
require.Equal(t, RedactedSecretPlaceholder, out.EnvVars["HF_TOKEN"])
// Non-secret fields stay visible.
require.Equal(t, "https://api.e2b.dev", out.E2B.APIURL)
require.Equal(t, "e2b", out.SandboxType)
// Original must be untouched.
require.Equal(t, "e2b-secret", cfg.E2B.APIKey)
require.Equal(t, "hf-secret", cfg.EnvVars["HF_TOKEN"])
}
func TestSandboxConfigForResponseMasksInjectedHeaderSecrets(t *testing.T) {
cfg := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{
CubeRules: []CubeEgressRule{{
Name: "allow-api",
Host: "api.example.com",
Inject: []CubeHeaderInject{{Header: "Authorization", Secret: "cube-secret"}},
}},
E2BHostRules: []E2BHostRule{{
Host: "api.example.com",
Headers: map[string]string{"X-Key": "e2b-secret", "X-Empty": ""},
}},
},
}
out := SandboxConfigForResponse(cfg, true)
require.Equal(t, RedactedSecretPlaceholder, out.Network.CubeRules[0].Inject[0].Secret)
require.Equal(t, RedactedSecretPlaceholder, out.Network.E2BHostRules[0].Headers["X-Key"])
require.Empty(t, out.Network.E2BHostRules[0].Headers["X-Empty"],
"an unset header value must not become a placeholder")
require.Equal(t, "cube-secret", cfg.Network.CubeRules[0].Inject[0].Secret,
"masking must not mutate the stored config")
}
func TestSandboxConfigForResponseSkipsMaskingWhenDisabled(t *testing.T) {
cfg := &TenantSandboxConfig{E2B: &E2BSandboxConfig{APIKey: "e2b-secret"}}
out := SandboxConfigForResponse(cfg, false)
require.Equal(t, "e2b-secret", out.E2B.APIKey)
}
func TestSandboxConfigForResponseEmptySecretStaysEmpty(t *testing.T) {
cfg := &TenantSandboxConfig{E2B: &E2BSandboxConfig{APIKey: ""}}
out := SandboxConfigForResponse(cfg, true)
require.Empty(t, out.E2B.APIKey, "an unset secret must not become a placeholder")
}
func TestSandboxConfigForResponseNil(t *testing.T) {
require.Nil(t, SandboxConfigForResponse(nil, true))
}
func TestMergeSandboxConfigForUpdatePreservesRedactedSecrets(t *testing.T) {
existing := &TenantSandboxConfig{
E2B: &E2BSandboxConfig{APIKey: "old-e2b"},
Cube: &CubeSandboxConfig{APIKey: "old-cube"},
EnvVars: map[string]string{"HF_TOKEN": "old-hf", "GONE": "old-gone"},
}
incoming := &TenantSandboxConfig{
E2B: &E2BSandboxConfig{APIKey: RedactedSecretPlaceholder}, // untouched by user
Cube: &CubeSandboxConfig{APIKey: "new-cube"}, // user typed a new key
EnvVars: map[string]string{"HF_TOKEN": RedactedSecretPlaceholder},
}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, "old-e2b", out.E2B.APIKey, "placeholder must resolve to the stored secret")
require.Equal(t, "new-cube", out.Cube.APIKey, "an explicitly typed secret must win")
require.Equal(t, "old-hf", out.EnvVars["HF_TOKEN"])
require.NotContains(t, out.EnvVars, "GONE", "env vars removed by the user must not resurrect")
}
func TestMergeSandboxConfigForUpdatePreservesInjectedHeaderSecrets(t *testing.T) {
existing := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{
CubeRules: []CubeEgressRule{{
Name: "allow-api",
Inject: []CubeHeaderInject{
{Header: "Authorization", Secret: "old-auth"},
{Header: "X-Trace", Secret: "old-trace"},
},
}},
E2BHostRules: []E2BHostRule{{
Host: "api.example.com",
Headers: map[string]string{"X-Key": "old-key", "X-Other": "old-other"},
}},
},
}
incoming := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{
CubeRules: []CubeEgressRule{{
Name: "allow-api",
Inject: []CubeHeaderInject{
{Header: "Authorization", Secret: RedactedSecretPlaceholder}, // untouched
{Header: "X-Trace", Secret: "new-trace"}, // retyped
},
}},
E2BHostRules: []E2BHostRule{{
Host: "api.example.com",
Headers: map[string]string{"X-Key": RedactedSecretPlaceholder},
}},
},
}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, "old-auth", out.Network.CubeRules[0].Inject[0].Secret)
require.Equal(t, "new-trace", out.Network.CubeRules[0].Inject[1].Secret)
require.Equal(t, "old-key", out.Network.E2BHostRules[0].Headers["X-Key"])
// A header the admin deleted must actually go, not be restored.
require.NotContains(t, out.Network.E2BHostRules[0].Headers, "X-Other")
}
func TestMergeSandboxConfigForUpdateMatchesInjectedHeadersCaseInsensitively(t *testing.T) {
existing := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{
CubeRules: []CubeEgressRule{{
Name: "Allow-API",
Inject: []CubeHeaderInject{{Header: "Authorization", Secret: "old-auth"}},
}},
E2BHostRules: []E2BHostRule{{
Host: "API.example.com",
Headers: map[string]string{"X-Key": "old-key"},
}},
},
}
incoming := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{
CubeRules: []CubeEgressRule{{
Name: "allow-api",
Inject: []CubeHeaderInject{{Header: "authorization", Secret: RedactedSecretPlaceholder}},
}},
E2BHostRules: []E2BHostRule{{
Host: " api.example.com ",
Headers: map[string]string{"x-key": RedactedSecretPlaceholder},
}},
},
}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, "old-auth", out.Network.CubeRules[0].Inject[0].Secret,
"HTTP header case and rule-name case must not drop the stored secret")
require.Equal(t, "old-key", out.Network.E2BHostRules[0].Headers["x-key"],
"host whitespace and header case must not drop the stored secret")
}
func TestMergeSandboxConfigForUpdateClearsPublicInbound(t *testing.T) {
incoming := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{AllowPublicInbound: true, AllowOut: []string{"1.1.1.1"}},
}
out := MergeSandboxConfigForUpdate(incoming, nil)
require.NotNil(t, out.Network)
require.False(t, out.Network.AllowPublicInbound,
"inbound cannot be opened from a saved payload; the field is accepted then cleared")
require.Equal(t, []string{"1.1.1.1"}, out.Network.AllowOut)
}
func TestMergeSandboxConfigForUpdateDropsNetworkWhenIncomingOmitsIt(t *testing.T) {
existing := &TenantSandboxConfig{
Network: &SandboxNetworkPolicy{DenyEgressByDefault: true},
}
out := MergeSandboxConfigForUpdate(&TenantSandboxConfig{}, existing)
require.Nil(t, out.Network,
"network is an editor-owned field: omitting it must clear it, unlike SkillImage")
}
func TestMergeSandboxConfigForUpdateHandlesNilExisting(t *testing.T) {
incoming := &TenantSandboxConfig{E2B: &E2BSandboxConfig{APIKey: RedactedSecretPlaceholder}}
out := MergeSandboxConfigForUpdate(incoming, nil)
require.Empty(t, out.E2B.APIKey, "placeholder with no stored value resolves to empty")
}
func TestMergeSandboxConfigForUpdateNilIncoming(t *testing.T) {
require.Nil(t, MergeSandboxConfigForUpdate(nil, &TenantSandboxConfig{}))
}
func TestMergeSandboxConfigForUpdatePreservesSkillImage(t *testing.T) {
existing := &TenantSandboxConfig{
E2B: &E2BSandboxConfig{APIKey: "old-e2b"},
SkillImage: &SkillImageConfig{SnapshotID: "snap-1", OwnerFingerprint: "fp-1", Generation: 3},
}
incoming := &TenantSandboxConfig{
E2B: &E2BSandboxConfig{APIKey: RedactedSecretPlaceholder},
SkillImage: &SkillImageConfig{SnapshotID: "forged-snap", OwnerFingerprint: "forged-fp"},
}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, "snap-1", out.SkillImage.SnapshotID,
"a settings save must not replace the install-owned snapshot pointer")
require.Equal(t, "fp-1", out.SkillImage.OwnerFingerprint)
require.Equal(t, 3, out.SkillImage.Generation)
out.SkillImage.SnapshotID = "mutated"
require.Equal(t, "snap-1", existing.SkillImage.SnapshotID,
"merge must copy SkillImage so later mutation cannot touch the stored row")
}
func TestMergeSandboxConfigForUpdateIgnoresIncomingSkillImageOnCreate(t *testing.T) {
incoming := &TenantSandboxConfig{
E2B: &E2BSandboxConfig{APIKey: "new-e2b"},
SkillImage: &SkillImageConfig{SnapshotID: "forged-snap"},
}
out := MergeSandboxConfigForUpdate(incoming, nil)
require.Nil(t, out.SkillImage, "create must not accept a client-supplied skill image")
}
// The editor never sends skill_image: that pointer is written only by an
// install or a removal. A merge that copied the incoming payload as-is would
// therefore wipe a live snapshot on every "保存运行配置", leaving the skill
// rows in place while every session fell back to the base template. VolumeMount
// is covered here too because the editor omits it for the same reason.
func TestMergeSandboxConfigForUpdateKeepsSkillImageWhenEditorOmitsIt(t *testing.T) {
existing := &TenantSandboxConfig{
SandboxType: "cube",
SkillImage: &SkillImageConfig{
SnapshotID: "snap-2",
Generation: 2,
BaseTemplateID: "base-template",
OwnerFingerprint: "fp-1",
},
VolumeMount: &VolumeMountConfig{Enabled: true, VolumeID: "vol-1"},
}
incoming := &TenantSandboxConfig{
SandboxType: "cube",
Cube: &CubeSandboxConfig{APIURL: "https://cube.example.com"},
}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.NotNil(t, out.SkillImage)
require.Equal(t, "snap-2", out.SkillImage.SnapshotID)
require.Equal(t, 2, out.SkillImage.Generation)
require.Equal(t, "base-template", out.SkillImage.BaseTemplateID)
require.Equal(t, "fp-1", out.SkillImage.OwnerFingerprint)
require.NotNil(t, out.VolumeMount)
require.Equal(t, "vol-1", out.VolumeMount.VolumeID)
out.SkillImage.SnapshotID = "mutated"
require.Equal(t, "snap-2", existing.SkillImage.SnapshotID,
"merge must copy the stored image, not share the pointer")
}
func TestMergeSandboxConfigForUpdateKeepsSkillRolloutWhenEditorOmitsIt(t *testing.T) {
existing := &TenantSandboxConfig{
SandboxType: "cube",
SkillRollout: SkillRolloutNewSession,
}
incoming := &TenantSandboxConfig{
SandboxType: "cube",
Cube: &CubeSandboxConfig{APIURL: "https://cube.example.com"},
}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, SkillRolloutNewSession, out.SkillRollout,
"a runtime save must not reset the skills-panel rollout choice")
}
func TestMergeSandboxConfigForUpdateHonoursExplicitSkillRollout(t *testing.T) {
existing := &TenantSandboxConfig{SkillRollout: SkillRolloutNewSession}
incoming := &TenantSandboxConfig{SkillRollout: SkillRolloutNextTurn}
out := MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, SkillRolloutNextTurn, out.SkillRollout)
}
func TestMergeSandboxConfigForUpdateDoesNotMutateInputs(t *testing.T) {
existing := &TenantSandboxConfig{E2B: &E2BSandboxConfig{APIKey: "old-e2b"}}
incoming := &TenantSandboxConfig{
E2B: &E2BSandboxConfig{APIKey: RedactedSecretPlaceholder},
EnvVars: map[string]string{"HF_TOKEN": RedactedSecretPlaceholder},
}
_ = MergeSandboxConfigForUpdate(incoming, existing)
require.Equal(t, RedactedSecretPlaceholder, incoming.E2B.APIKey,
"merge must not mutate the incoming payload")
require.Equal(t, "old-e2b", existing.E2B.APIKey,
"merge must not mutate the stored config")
}
+153
View File
@@ -0,0 +1,153 @@
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWebSearchConfigForResponse_MasksSecrets(t *testing.T) {
cfg := &WebSearchConfig{
APIKey: "search-secret",
ProxyURL: "http://proxy.internal:8080",
}
resp := WebSearchConfigForResponse(cfg, true)
require.NotNil(t, resp)
assert.Empty(t, resp.APIKey)
assert.Equal(t, RedactedSecretPlaceholder, resp.ProxyURL)
}
func TestWebSearchConfigForResponse_Unmasked(t *testing.T) {
cfg := &WebSearchConfig{APIKey: "search-secret", ProxyURL: "http://proxy"}
resp := WebSearchConfigForResponse(cfg, false)
require.NotNil(t, resp)
assert.Equal(t, "search-secret", resp.APIKey)
assert.Equal(t, "http://proxy", resp.ProxyURL)
}
func TestMergeWebSearchConfigForUpdate_PreservesRedactedSecrets(t *testing.T) {
existing := &WebSearchConfig{APIKey: "stored-key", ProxyURL: "http://stored"}
incoming := &WebSearchConfig{
APIKey: "",
ProxyURL: RedactedSecretPlaceholder,
MaxResults: 10,
}
merged := MergeWebSearchConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
assert.Equal(t, "stored-key", merged.APIKey)
assert.Equal(t, "http://stored", merged.ProxyURL)
assert.Equal(t, 10, merged.MaxResults)
}
func TestMergeParserEngineConfigForUpdate_PreservesRedactedSecrets(t *testing.T) {
existing := &ParserEngineConfig{
MinerUAPIKey: "mineru-secret",
PaddleOCRVLCloudToken: "paddle-secret",
MinerUEndpoint: "http://mineru",
}
incoming := &ParserEngineConfig{
MinerUAPIKey: RedactedSecretPlaceholder,
PaddleOCRVLCloudToken: RedactedSecretPlaceholder,
MinerUEndpoint: "http://mineru-new",
}
merged := MergeParserEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
assert.Equal(t, "mineru-secret", merged.MinerUAPIKey)
assert.Equal(t, "paddle-secret", merged.PaddleOCRVLCloudToken)
assert.Equal(t, "http://mineru-new", merged.MinerUEndpoint)
}
func TestMergeParserEngineConfigForUpdate_PreservesLegacyChatParserRules(t *testing.T) {
existing := &ParserEngineConfig{
ChatParserEngineRules: []ParserEngineRule{
{FileTypes: []string{"pdf"}, Engine: "mineru"},
},
}
incoming := &ParserEngineConfig{
MinerUEndpoint: "http://mineru-new",
}
merged := MergeParserEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
require.Len(t, merged.ChatParserEngineRules, 1)
assert.Equal(t, "mineru", merged.ChatParserEngineRules[0].Engine)
}
func TestMergeStorageEngineConfigForUpdate_PreservesRedactedSecrets(t *testing.T) {
existing := &StorageEngineConfig{
DefaultProvider: "minio",
MinIO: &MinIOEngineConfig{
AccessKeyID: "access-id",
SecretAccessKey: "secret-key",
BucketName: "bucket",
},
}
incoming := &StorageEngineConfig{
DefaultProvider: "minio",
MinIO: &MinIOEngineConfig{
AccessKeyID: RedactedSecretPlaceholder,
SecretAccessKey: RedactedSecretPlaceholder,
BucketName: "bucket-new",
},
}
merged := MergeStorageEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
require.NotNil(t, merged.MinIO)
assert.Equal(t, "access-id", merged.MinIO.AccessKeyID)
assert.Equal(t, "secret-key", merged.MinIO.SecretAccessKey)
assert.Equal(t, "bucket-new", merged.MinIO.BucketName)
}
func TestMergeStorageEngineConfigForUpdate_ClearsS3Credentials(t *testing.T) {
existing := &StorageEngineConfig{
DefaultProvider: "s3",
S3: &S3EngineConfig{
AccessKey: "stored-access-key",
SecretKey: "stored-secret-key",
Region: "us-east-1",
BucketName: "bucket",
},
}
t.Run("empty credentials enable the default credential chain", func(t *testing.T) {
incoming := &StorageEngineConfig{
DefaultProvider: "s3",
S3: &S3EngineConfig{
AccessKey: "",
SecretKey: "",
Region: "us-east-1",
BucketName: "bucket",
},
}
merged := MergeStorageEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
require.NotNil(t, merged.S3)
assert.Empty(t, merged.S3.AccessKey)
assert.Empty(t, merged.S3.SecretKey)
})
t.Run("redacted placeholders preserve stored credentials", func(t *testing.T) {
incoming := &StorageEngineConfig{
DefaultProvider: "s3",
S3: &S3EngineConfig{
AccessKey: RedactedSecretPlaceholder,
SecretKey: RedactedSecretPlaceholder,
Region: "us-east-1",
BucketName: "bucket",
},
}
merged := MergeStorageEngineConfigForUpdate(incoming, existing)
require.NotNil(t, merged)
require.NotNil(t, merged.S3)
assert.Equal(t, "stored-access-key", merged.S3.AccessKey)
assert.Equal(t, "stored-secret-key", merged.S3.SecretKey)
})
}
func TestParserEngineConfigForResponse_NilSafe(t *testing.T) {
assert.Nil(t, ParserEngineConfigForResponse(nil, true))
}
func TestStorageEngineConfigForResponse_NilSafe(t *testing.T) {
assert.Nil(t, StorageEngineConfigForResponse(nil, true))
}
+98
View File
@@ -0,0 +1,98 @@
package types
// ContextKey defines a type for context keys to avoid string collision
type ContextKey string
const (
// TenantIDContextKey is the context key for tenant ID
TenantIDContextKey ContextKey = "TenantID"
// TenantInfoContextKey is the context key for tenant information
TenantInfoContextKey ContextKey = "TenantInfo"
// RequestIDContextKey is the context key for request ID
RequestIDContextKey ContextKey = "RequestID"
// LoggerContextKey is the context key for logger
LoggerContextKey ContextKey = "Logger"
// UserContextKey is the context key for user information
UserContextKey ContextKey = "User"
// UserIDContextKey is the context key for user ID
UserIDContextKey ContextKey = "UserID"
// PrincipalContextKey is the context key for the terminal caller principal.
PrincipalContextKey ContextKey = "Principal"
// TenantAPIKeyScopeContextKey carries per-API-key operation and KB scopes.
TenantAPIKeyScopeContextKey ContextKey = "TenantAPIKeyScope"
// AccessPolicySnapshotContextKey carries workspace module and resource restrictions.
AccessPolicySnapshotContextKey ContextKey = "AccessPolicySnapshot"
// TenantRoleContextKey is the context key for the caller's TenantRole
// in the currently active tenant (loaded by the auth middleware from
// the tenant_members table). See TenantRoleFromContext.
TenantRoleContextKey ContextKey = "TenantRole"
// SessionTenantIDContextKey is the context key for session owner's tenant ID.
// When set (e.g. in pipeline with shared agent), session/message lookups use this instead of TenantIDContextKey.
SessionTenantIDContextKey ContextKey = "SessionTenantID"
// SessionIDContextKey carries the current session ID through the chat pipeline
// so that stateful sandbox backends (notably CubeSandbox) can route script
// execution to the persistent, per-session MicroVM instance.
SessionIDContextKey ContextKey = "SessionID"
// SandboxTenantIDContextKey carries the tenant that owns the session whose
// sandbox is being addressed, which is NOT always the tenant in
// TenantIDContextKey: a shared agent runs under the agent owner's tenant so
// that models/KBs/named sandbox configs resolve in the sharing workspace.
//
// The session→sandbox binding must stay keyed by the session owner instead,
// because session deletion tears the sandbox down from a plain HTTP request
// that only ever knows the session's own tenant. Keying the binding on the
// borrowed tenant would leave that teardown looking up a key nobody wrote,
// abandoning a paused MicroVM that keeps billing. It is deliberately a
// distinct key from SessionTenantIDContextKey, which also re-scopes
// session/message repository lookups. See SandboxTenantIDFromContext.
SandboxTenantIDContextKey ContextKey = "SandboxTenantID"
// EmbedQueryContextKey is the context key for embedding query text
EmbedQueryContextKey ContextKey = "EmbedQuery"
// WikiEditSourceContextKey carries who is authoring the current wiki
// page write (user / agent / revert). Absent means the wiki ingest
// pipeline. See types.WithWikiEditSource.
WikiEditSourceContextKey ContextKey = "WikiEditSource"
// LanguageContextKey is the context key for user language preference (e.g. "zh-CN", "en-US")
LanguageContextKey ContextKey = "Language"
// EmbedVisitorContextKey is the anonymous visitor id for embed OAuth isolation.
EmbedVisitorContextKey ContextKey = "EmbedVisitorID"
// EmbedChannelContextKey stores the authenticated embed channel (an
// *EmbedChannel) on the request context. Declared here rather than in the
// middleware that authenticates it, for the same reason as
// LangfuseTraceContextKey: the clone table in context_clone.go has to name
// every context key, and middleware imports this package, so a key
// declared there could only be referred to by its raw string value.
EmbedChannelContextKey ContextKey = "EmbedChannel"
// LangfuseTraceContextKey carries the active Langfuse *Trace across the
// request lifecycle. Defined here (not inside the langfuse package) so
// that logger.CloneContext can preserve it without importing langfuse.
LangfuseTraceContextKey ContextKey = "LangfuseTrace"
// SystemAdminContextKey is the context key indicating whether the user is a system administrator
SystemAdminContextKey ContextKey = "SystemAdmin"
// BackgroundTaskContextKey marks a context whose model calls originate from
// an asynq background worker (document parse / summary / question / graph /
// multimodal enrichment) rather than a user-facing HTTP request. The chat
// concurrency governor uses this to throttle only background LLM traffic,
// leaving interactive chat latency untouched. See WithBackgroundTask.
BackgroundTaskContextKey ContextKey = "BackgroundTask"
// LLMCallPurposeContextKey labels the product-level reason for a model
// request (for example agent_round or wiki_page_modify).
LLMCallPurposeContextKey ContextKey = "LLMCallPurpose"
// LLMPromptPrefixFingerprintContextKey carries a non-sensitive hash of the
// intended reusable prompt prefix for cache diagnostics.
LLMPromptPrefixFingerprintContextKey ContextKey = "LLMPromptPrefixFingerprint"
// MCPOAuthNonInteractiveContextKey marks a request whose channel cannot
// resolve an in-conversation MCP OAuth prompt (e.g. an IM bot: there is no
// live client to click "Authorize" and call the resolve endpoint). When set,
// the agent emits a one-shot authorization notice and continues instead of
// blocking until the OAuth wait times out. See IsMCPOAuthNonInteractive.
MCPOAuthNonInteractiveContextKey ContextKey = "MCPOAuthNonInteractive"
// ChatParserEngineContextKey carries the resolved parser engine
// from the agent's ChatParserEngineRules for chat attachment processing.
ChatParserEngineContextKey ContextKey = "ChatParserEngine"
)
// String returns the string representation of the context key
func (c ContextKey) String() string {
return string(c)
}
+138
View File
@@ -0,0 +1,138 @@
package types
import "sort"
// contextCloneAcrossDetach answers one question for every context key in the
// codebase: when a request's context is detached for background work by
// logger.CloneContext, does this key travel with it?
//
// It is an exhaustive decision table rather than a list of keys to keep,
// because the two ways of getting this wrong fail in opposite directions and
// neither default is safe. Dropping a key that carries a RESTRICTION fails
// open: an agent's memory opt-out or a session's sandbox tenant that goes
// missing lets background work do what the request forbade. Keeping a key that
// carries a GRANT fails open the other way: a per-request access decision or a
// suppressed-audit marker that outlives its request widens what the background
// work may do. Since no default is universally right, every key states its
// own answer here, next to where such keys are declared, and
// TestEveryContextKeyDeclaresACloneDecision fails when a new key arrives
// without one instead of letting it opt out by silence.
//
// false is a valid, meaningful answer — it means "deliberately does not
// survive a detach" — and is not the same as being absent.
var contextCloneAcrossDetach = map[ContextKey]bool{
// Caller identity and workspace scope. Background work runs as the same
// principal in the same workspace, so all of this has to survive; a
// detached goroutine that loses its tenant reads another tenant's rows or
// none at all.
TenantIDContextKey: true,
TenantInfoContextKey: true,
UserContextKey: true,
UserIDContextKey: true,
PrincipalContextKey: true,
SystemAdminContextKey: true,
// TenantRoleContextKey: the caller's resolved role in the active tenant
// (PR 2 #1303). Must survive for the same reason as TenantIDContextKey —
// any handler that does `ctx := logger.CloneContext(c.Request.Context())`
// and then reads the role via TenantRoleFromContext would otherwise see
// the type-zero TenantRole and fall back to Viewer, blocking even Owners.
TenantRoleContextKey: true,
// Per-API-key operation and KB scopes: a restriction, so dropping it would
// hand background work broader reach than the key it came from.
TenantAPIKeyScopeContextKey: true,
// Losing the authenticated resource policy would make detached retrieval
// fall back to the unrestricted background-worker behavior.
AccessPolicySnapshotContextKey: true,
// Session scope. SessionTenantID re-scopes session/message lookups, while
// SandboxTenantID keys the session→sandbox binding to the session owner
// even when a shared agent borrowed another tenant. Dropping the latter
// would silently re-key every binding onto the borrowed tenant, because
// setupSSEStream builds its async context through CloneContext, and
// abandon a paused MicroVM that keeps billing.
SessionIDContextKey: true,
SessionTenantIDContextKey: true,
SandboxTenantIDContextKey: true,
// Embed callers: the anonymous visitor id isolates embed OAuth, so losing
// it would merge visitors together.
EmbedQueryContextKey: true,
EmbedVisitorContextKey: true,
// Diagnostics and presentation that background work is expected to keep.
// The Langfuse trace in particular must stay alive so the LLM / embedder /
// reranker / VLM / ASR wrappers attach their generations to the trace
// GinMiddleware opened, rather than each auto-creating an orphan.
LoggerContextKey: true,
RequestIDContextKey: true,
LanguageContextKey: true,
LangfuseTraceContextKey: true,
// The agent-level opt-out from long-term memory. Recall is gated inside
// the QA services, but extraction, the explicit "remember this" route and
// document affinity all run from a context descended from a CloneContext.
// Dropping this key would let an agent that cannot read memory keep
// writing to it.
MemoryDisabledContextKey: true,
// Marks model calls as coming from an asynq worker so the per-model chat
// concurrency governor throttles them, leaving interactive chat latency
// alone. Document ingestion detaches mid-flight — knowledge_create hands
// processChunks to a goroutine on a cloned context, and processChunks is
// what vectorises every chunk. Dropping the mark there would let exactly
// the ingestion storm the governor exists to contain run past it. Keeping
// it can only over-throttle a context that is background by definition.
BackgroundTaskContextKey: true,
// Marks a channel that cannot resolve an in-conversation MCP OAuth prompt
// (an IM bot has no live client to click "Authorize"). Dropping it makes
// the agent block on the OAuth wait for every unauthorized service instead
// of emitting its one-shot notice, so the failure is a stalled reply.
// A detached context has no live client either, which is what this says.
MCPOAuthNonInteractiveContextKey: true,
// ---- Deliberately does not survive a detach ----
//
// false here is a decision, not an oversight: each of these describes one
// request, one call or one attachment rather than the caller, so carrying
// it into detached work would describe that work wrongly.
// Labels for a single model request, set immediately before the call by
// every call site that has an opinion. An inherited label is worse than no
// label, because usage is aggregated by purpose and a background call
// would be filed under whatever its parent was doing.
LLMCallPurposeContextKey: false,
LLMPromptPrefixFingerprintContextKey: false,
// Who is authoring one wiki page write. Absent deliberately means the wiki
// ingest pipeline, which is what detached work should look like; an
// inherited "user" would attribute a background rewrite to a person.
WikiEditSourceContextKey: false,
// The parser engine resolved from one agent's ChatParserEngineRules for
// one attachment's file type. Read by the attachment processor on the same
// context that set it, and meaningless for anything else.
ChatParserEngineContextKey: false,
// The authenticated embed channel. Every reader takes it straight off the
// request context inside the embed handler that authenticated it; nothing
// downstream of a detach reads it.
EmbedChannelContextKey: false,
}
// ContextKeysClonedAcrossDetach returns the keys logger.CloneContext carries
// into a detached context, sorted so the result does not depend on map order.
func ContextKeysClonedAcrossDetach() []ContextKey {
keys := make([]ContextKey, 0, len(contextCloneAcrossDetach))
for key, clone := range contextCloneAcrossDetach {
if clone {
keys = append(keys, key)
}
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
return keys
}
// ContextCloneDecision reports whether the key survives a detach, and whether
// any decision has been recorded for it at all. The second return value is
// what the coverage test uses to tell a deliberate false from an omission.
func ContextCloneDecision(key ContextKey) (clone bool, declared bool) {
clone, declared = contextCloneAcrossDetach[key]
return clone, declared
}
@@ -0,0 +1,145 @@
package types
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
// declaredContextKeys walks the source of internal/ and returns every declared
// context key by its underlying string value, mapped to where it was found.
//
// The keys are collected from the source rather than from a hand-written list
// because a hand-written list has the very failure mode this test exists to
// close: whoever forgets to register a new key in the clone table is exactly
// the person who would forget to add it to the list guarding the table.
func declaredContextKeys(t *testing.T) map[ContextKey]string {
t.Helper()
found := make(map[ContextKey]string)
fset := token.NewFileSet()
err := filepath.WalkDir("..", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
// Test files are skipped: a key invented by a test never reaches a
// detached production context, so forcing a decision for it would be
// noise.
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, parseErr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if parseErr != nil {
return parseErr
}
for _, decl := range file.Decls {
generic, ok := decl.(*ast.GenDecl)
if !ok || generic.Tok != token.CONST {
continue
}
for _, spec := range generic.Specs {
value, ok := spec.(*ast.ValueSpec)
if !ok || !isContextKeyType(value.Type) {
continue
}
for _, expr := range value.Values {
literal, ok := expr.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
continue
}
unquoted, unquoteErr := strconv.Unquote(literal.Value)
if unquoteErr != nil {
return unquoteErr
}
found[ContextKey(unquoted)] = path
}
}
}
return nil
})
if err != nil {
t.Fatalf("walking internal/ for context key declarations: %v", err)
}
if len(found) == 0 {
t.Fatal("found no context key declarations, so this test is not guarding anything")
}
return found
}
// isContextKeyType reports whether a const is typed as this package's
// ContextKey, written either bare (inside types) or qualified (outside it).
func isContextKeyType(expr ast.Expr) bool {
switch typed := expr.(type) {
case *ast.Ident:
return typed.Name == "ContextKey"
case *ast.SelectorExpr:
return typed.Sel.Name == "ContextKey"
}
return false
}
// A context key that reaches logger.CloneContext without a recorded decision
// opts out of surviving the detach by accident, and nothing reports it: the
// background work simply runs without the value. That is how an agent's
// long-term memory opt-out came to stop recall while leaving the write path
// running, and how the session→sandbox binding was once re-keyed onto a
// borrowed tenant. Either answer is fine here; silence is not.
func TestEveryContextKeyDeclaresACloneDecision(t *testing.T) {
t.Parallel()
for key, path := range declaredContextKeys(t) {
if _, declared := ContextCloneDecision(key); !declared {
t.Errorf(
"context key %q (declared in %s) has no entry in contextCloneAcrossDetach; "+
"add one saying whether it should survive logger.CloneContext",
key, path,
)
}
}
}
// The reverse direction: an entry left behind by a deleted key turns the table
// into folklore, and the next reader cannot tell which entries still describe
// something real.
func TestTheCloneTableHasNoEntriesForKeysThatNoLongerExist(t *testing.T) {
t.Parallel()
declared := declaredContextKeys(t)
for key := range contextCloneAcrossDetach {
if _, ok := declared[key]; !ok {
t.Errorf(
"contextCloneAcrossDetach has an entry for %q, but no context key with that value is declared in internal/",
key,
)
}
}
}
// The table is what CloneContext iterates, so a key marked as surviving has to
// come back out of the accessor, and one marked as dropped must not.
func TestClonedKeysAreExactlyTheOnesMarkedToSurvive(t *testing.T) {
t.Parallel()
surviving := make(map[ContextKey]bool)
for _, key := range ContextKeysClonedAcrossDetach() {
if surviving[key] {
t.Errorf("key %q is returned twice", key)
}
surviving[key] = true
}
for key, clone := range contextCloneAcrossDetach {
if clone != surviving[key] {
t.Errorf("key %q: table says clone=%v, accessor says %v", key, clone, surviving[key])
}
}
}
+372
View File
@@ -0,0 +1,372 @@
package types
import (
"context"
"os"
"strings"
)
// EnvLanguage returns the WEKNORA_LANGUAGE environment variable value, or empty string if unset.
func EnvLanguage() string {
return strings.TrimSpace(os.Getenv("WEKNORA_LANGUAGE"))
}
// DefaultLanguage returns the configured default language locale.
// It reads the WEKNORA_LANGUAGE environment variable; if unset, falls back to "zh-CN".
func DefaultLanguage() string {
if lang := EnvLanguage(); lang != "" {
return lang
}
return "zh-CN"
}
// TenantIDFromContext extracts the tenant ID from ctx.
// Returns (0, false) when the key is absent or the value is not uint64.
func TenantIDFromContext(ctx context.Context) (uint64, bool) {
v, ok := ctx.Value(TenantIDContextKey).(uint64)
return v, ok
}
// MustTenantIDFromContext extracts the tenant ID from ctx, panicking if missing.
func MustTenantIDFromContext(ctx context.Context) uint64 {
v, ok := TenantIDFromContext(ctx)
if !ok {
panic("types.TenantIDContextKey not set in context")
}
return v
}
// TenantInfoFromContext extracts the *Tenant from ctx.
func TenantInfoFromContext(ctx context.Context) (*Tenant, bool) {
v, ok := ctx.Value(TenantInfoContextKey).(*Tenant)
return v, ok && v != nil
}
// RequestIDFromContext extracts the request ID string from ctx.
func RequestIDFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(RequestIDContextKey).(string)
return v, ok && v != ""
}
// UserIDFromContext extracts the user ID string from ctx.
func UserIDFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(UserIDContextKey).(string)
return v, ok && v != ""
}
// WithWikiEditSource marks ctx so wiki page writes performed under it are
// attributed to the given edit source (WikiEditSourceUser / Agent / Revert).
// Writes without the mark are attributed to the ingest pipeline.
func WithWikiEditSource(ctx context.Context, source string) context.Context {
return context.WithValue(ctx, WikiEditSourceContextKey, NormalizeWikiEditSource(source))
}
// WikiEditSourceFromContext returns the wiki edit source carried by ctx,
// defaulting to WikiEditSourcePipeline when absent.
func WikiEditSourceFromContext(ctx context.Context) string {
v, _ := ctx.Value(WikiEditSourceContextKey).(string)
return NormalizeWikiEditSource(v)
}
// TaskInitiator is the authenticated caller that submitted an asynchronous
// task. Workers restore it into their context so audit entries describe who
// initiated the operation, while tasks created by schedulers remain
// attributable to the system.
type TaskInitiator struct {
UserID string `json:"user_id,omitempty"`
Role TenantRole `json:"role,omitempty"`
}
// TaskInitiatorFromContext snapshots the real caller identity for a task
// payload. Synthetic API-key users are service identities, not people, and are
// intentionally left empty so the activity feed presents them as system work.
func TaskInitiatorFromContext(ctx context.Context) TaskInitiator {
userID, ok := UserIDFromContext(ctx)
if !ok || IsSyntheticUserID(userID) {
return TaskInitiator{}
}
return TaskInitiator{UserID: userID, Role: TenantRoleFromContext(ctx)}
}
// Apply restores a captured task initiator onto a worker context. Empty or
// legacy payloads are a no-op and therefore retain the system-task fallback.
func (i TaskInitiator) Apply(ctx context.Context) context.Context {
if i.UserID == "" {
return ctx
}
ctx = context.WithValue(ctx, UserIDContextKey, i.UserID)
if i.Role.IsValid() {
ctx = context.WithValue(ctx, TenantRoleContextKey, i.Role)
}
return ctx
}
// IsSyntheticUserID reports whether id refers to the synthetic system
// user that the X-API-Key auth path attaches to each tenant
// (User.ID = "system-<tenantID>"). These users have no real human
// behind them and no tenant_members row, so RBAC ownership matching
// against them is never meaningful — service-layer code that records
// "the creator" should skip storing this ID and treat the resource as
// tenant-owned instead.
//
// Kept minimal on purpose: the prefix and "all digits afterwards"
// invariant comes from middleware/auth.go's User construction for the
// API-key branch. If that prefix changes, update both sides.
func IsSyntheticUserID(id string) bool {
const prefix = "system-"
if len(id) <= len(prefix) {
return false
}
if id[:len(prefix)] != prefix {
return false
}
for _, r := range id[len(prefix):] {
if r < '0' || r > '9' {
return false
}
}
return true
}
// TenantRoleFromContext extracts the caller's TenantRole in the currently
// active tenant. Returns TenantRoleViewer when the key is absent so that
// callers fail closed (least privilege) if the auth middleware did not
// populate the role for some reason.
func TenantRoleFromContext(ctx context.Context) TenantRole {
v, ok := ctx.Value(TenantRoleContextKey).(TenantRole)
if !ok || !v.IsValid() {
return TenantRoleViewer
}
return v
}
// IsSystemAdminFromContext extracts the system admin flag from ctx.
// Returns false (fail-closed) when the key is absent.
func IsSystemAdminFromContext(ctx context.Context) bool {
v, ok := ctx.Value(SystemAdminContextKey).(bool)
if !ok {
return false
}
return v
}
// SessionTenantIDFromContext extracts the session-owner tenant ID from ctx.
// Falls back to TenantIDFromContext when the session key is absent.
func SessionTenantIDFromContext(ctx context.Context) (uint64, bool) {
v, ok := ctx.Value(SessionTenantIDContextKey).(uint64)
if ok && v != 0 {
return v, true
}
return TenantIDFromContext(ctx)
}
// WithSessionID returns a derived context carrying the current session ID.
// Stateful sandbox backends (e.g. CubeSandbox) rely on this key to route
// script execution to the per-session persistent MicroVM instance.
func WithSessionID(ctx context.Context, sessionID string) context.Context {
if sessionID == "" {
return ctx
}
return context.WithValue(ctx, SessionIDContextKey, sessionID)
}
// SessionIDFromContext extracts the current session ID from ctx.
// Returns ("", false) when the key is absent or the value is not a non-empty string.
func SessionIDFromContext(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
v, ok := ctx.Value(SessionIDContextKey).(string)
return v, ok && v != ""
}
// WithSandboxTenantID records the session-owner tenant that session→sandbox
// bindings must be keyed by, independently of the tenant the rest of the
// pipeline runs as. Callers set it wherever they swap TenantIDContextKey for a
// shared agent's workspace.
func WithSandboxTenantID(ctx context.Context, tenantID uint64) context.Context {
if tenantID == 0 {
return ctx
}
return context.WithValue(ctx, SandboxTenantIDContextKey, tenantID)
}
// SandboxTenantIDFromContext returns the tenant to key session→sandbox
// bindings by. It falls back to TenantIDFromContext, which is correct for every
// path that never borrows another workspace's tenant: there the request tenant
// already IS the session owner.
func SandboxTenantIDFromContext(ctx context.Context) (uint64, bool) {
if ctx == nil {
return 0, false
}
if v, ok := ctx.Value(SandboxTenantIDContextKey).(uint64); ok && v != 0 {
return v, true
}
return TenantIDFromContext(ctx)
}
// WithMCPOAuthNonInteractive marks ctx as originating from a channel that cannot
// complete an in-conversation MCP OAuth prompt (e.g. an IM bot). The agent uses
// this to emit a one-shot authorization notice instead of blocking on the OAuth
// wait until it times out. See MCPOAuthNonInteractiveContextKey.
func WithMCPOAuthNonInteractive(ctx context.Context) context.Context {
return context.WithValue(ctx, MCPOAuthNonInteractiveContextKey, true)
}
// IsMCPOAuthNonInteractive reports whether ctx was marked non-interactive for
// MCP OAuth (see WithMCPOAuthNonInteractive).
func IsMCPOAuthNonInteractive(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(MCPOAuthNonInteractiveContextKey).(bool)
return v
}
// WithBackgroundTask marks ctx as originating from an asynq background worker
// (document parse / summary / question / graph / multimodal enrichment). The
// chat concurrency governor throttles only background LLM traffic, so this flag
// determines whether the per-model concurrency limit applies. See
// BackgroundTaskContextKey.
func WithBackgroundTask(ctx context.Context) context.Context {
return context.WithValue(ctx, BackgroundTaskContextKey, true)
}
// IsBackgroundTask reports whether ctx was marked as a background worker task
// (see WithBackgroundTask). Returns false for interactive / HTTP request paths.
func IsBackgroundTask(ctx context.Context) bool {
if ctx == nil {
return false
}
v, _ := ctx.Value(BackgroundTaskContextKey).(bool)
return v
}
type taskRetryMetadata struct {
retried int
maxRetry int
}
// WithTaskRetryMetadata records retry counters for task executors that do not
// provide Asynq's native worker context, notably the Lite synchronous executor.
func WithTaskRetryMetadata(ctx context.Context, retried, maxRetry int) context.Context {
return context.WithValue(ctx, taskRetryMetadataContextKey{}, taskRetryMetadata{
retried: retried, maxRetry: maxRetry,
})
}
// TaskRetryMetadataFromContext returns retry counters supplied by a non-Asynq
// task executor. The boolean is false for ordinary request contexts.
func TaskRetryMetadataFromContext(ctx context.Context) (retried, maxRetry int, ok bool) {
if ctx == nil {
return 0, 0, false
}
metadata, ok := ctx.Value(taskRetryMetadataContextKey{}).(taskRetryMetadata)
if !ok {
return 0, 0, false
}
return metadata.retried, metadata.maxRetry, true
}
type taskRetryMetadataContextKey struct{}
// WithLLMCallMetadata annotates a provider call for cache observability. The
// fingerprint must be a hash, never raw prompt content.
func WithLLMCallMetadata(ctx context.Context, purpose, prefixFingerprint string) context.Context {
if strings.TrimSpace(purpose) != "" {
ctx = context.WithValue(ctx, LLMCallPurposeContextKey, strings.TrimSpace(purpose))
}
if strings.TrimSpace(prefixFingerprint) != "" {
ctx = context.WithValue(ctx, LLMPromptPrefixFingerprintContextKey, strings.TrimSpace(prefixFingerprint))
}
return ctx
}
// LLMCallMetadataFromContext returns the cache-observability labels attached
// by the orchestration layer.
func LLMCallMetadataFromContext(ctx context.Context) (purpose, prefixFingerprint string) {
if ctx == nil {
return "", ""
}
purpose, _ = ctx.Value(LLMCallPurposeContextKey).(string)
prefixFingerprint, _ = ctx.Value(LLMPromptPrefixFingerprintContextKey).(string)
return purpose, prefixFingerprint
}
// LanguageFromContext extracts the language locale string from ctx (e.g. "zh-CN", "en-US").
// Returns ("zh-CN", false) when the key is absent.
func LanguageFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(LanguageContextKey).(string)
return v, ok && v != ""
}
// ResolveLanguage resolves the effective locale for work that may carry its own
// language, in descending precedence: the explicit locale, the locale in ctx,
// then DefaultLanguage().
//
// Async workers must use this rather than reading a payload field directly: a
// task payload persisted before the language field existed, or enqueued from a
// background path that never passed through the HTTP language middleware,
// carries an empty locale. Interpolating that empty value into a prompt yields
// instructions like "Write in ." and lets the model pick a language at random.
func ResolveLanguage(ctx context.Context, locale string) string {
if locale = strings.TrimSpace(locale); locale != "" {
return locale
}
if ctxLocale, ok := LanguageFromContext(ctx); ok {
return ctxLocale
}
return DefaultLanguage()
}
// ResolveLanguageName is ResolveLanguage rendered as the human-readable name
// that prompt templates interpolate (e.g. "Chinese (Simplified)").
//
// It is idempotent over already-resolved names: LanguageLocaleName passes
// unknown values through, so re-resolving a display name returns it unchanged.
func ResolveLanguageName(ctx context.Context, locale string) string {
return LanguageLocaleName(ResolveLanguage(ctx, locale))
}
// LanguageFromContextOrDefault returns the locale carried by ctx, falling back
// to DefaultLanguage(). Use it when persisting a locale onto an async task
// payload so downstream workers never inherit an empty language.
func LanguageFromContextOrDefault(ctx context.Context) string {
return ResolveLanguage(ctx, "")
}
// LanguageNameFromContext returns the human-readable language name for use in prompts.
// e.g. "zh-CN" -> "Chinese (Simplified)", "en-US" -> "English", "ko-KR" -> "Korean"
// Falls back to DefaultLanguage() (WEKNORA_LANGUAGE env, then "zh-CN").
func LanguageNameFromContext(ctx context.Context) string {
return ResolveLanguageName(ctx, "")
}
// LanguageLocaleName maps a locale code to a human-readable language name for LLM prompts.
func LanguageLocaleName(locale string) string {
switch locale {
case "zh-CN", "zh", "zh-Hans":
return "Chinese (Simplified)"
case "zh-TW", "zh-HK", "zh-Hant":
return "Chinese (Traditional)"
case "en-US", "en", "en-GB":
return "English"
case "ko-KR", "ko":
return "Korean"
case "ja-JP", "ja":
return "Japanese"
case "ru-RU", "ru":
return "Russian"
case "fr-FR", "fr":
return "French"
case "de-DE", "de":
return "German"
case "es-ES", "es":
return "Spanish"
case "pt-BR", "pt":
return "Portuguese"
default:
// For unknown locales, return the locale itself
return locale
}
}
+249
View File
@@ -0,0 +1,249 @@
package types
import (
"context"
"testing"
)
func TestIsSyntheticUserID(t *testing.T) {
cases := []struct {
name string
id string
want bool
}{
{"matches system-<digits>", "system-1", true},
{"matches large tenant id", "system-1234567890", true},
{"empty string", "", false},
{"prefix only", "system-", false},
{"missing prefix", "1", false},
{"non-digit suffix", "system-abc", false},
{"mixed suffix", "system-1a2", false},
{"prefix with space", "system- 1", false},
{"uppercase prefix", "SYSTEM-1", false},
{"normal uuid user", "550e8400-e29b-41d4-a716-446655440000", false},
{"system uuid trap", "system-550e8400", false}, // contains '-'
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
got := IsSyntheticUserID(c.id)
if got != c.want {
t.Fatalf("IsSyntheticUserID(%q) = %v, want %v", c.id, got, c.want)
}
})
}
}
func TestLanguageLocaleName(t *testing.T) {
tests := []struct {
name string
locale string
expected string
}{
// Chinese (Simplified) variants
{"Chinese Simplified zh-CN", "zh-CN", "Chinese (Simplified)"},
{"Chinese Simplified zh", "zh", "Chinese (Simplified)"},
{"Chinese Simplified zh-Hans", "zh-Hans", "Chinese (Simplified)"},
// Chinese (Traditional) variants
{"Chinese Traditional zh-TW", "zh-TW", "Chinese (Traditional)"},
{"Chinese Traditional zh-HK", "zh-HK", "Chinese (Traditional)"},
{"Chinese Traditional zh-Hant", "zh-Hant", "Chinese (Traditional)"},
// English variants
{"English en-US", "en-US", "English"},
{"English en", "en", "English"},
{"English en-GB", "en-GB", "English"},
// Korean
{"Korean ko-KR", "ko-KR", "Korean"},
{"Korean ko", "ko", "Korean"},
// Japanese
{"Japanese ja-JP", "ja-JP", "Japanese"},
{"Japanese ja", "ja", "Japanese"},
// Russian
{"Russian ru-RU", "ru-RU", "Russian"},
{"Russian ru", "ru", "Russian"},
// French
{"French fr-FR", "fr-FR", "French"},
{"French fr", "fr", "French"},
// German
{"German de-DE", "de-DE", "German"},
{"German de", "de", "German"},
// Spanish
{"Spanish es-ES", "es-ES", "Spanish"},
{"Spanish es", "es", "Spanish"},
// Portuguese
{"Portuguese pt-BR", "pt-BR", "Portuguese"},
{"Portuguese pt", "pt", "Portuguese"},
// Unknown/fallback
{"Unknown locale", "unknown", "unknown"},
{"Empty locale", "", ""},
{"Arbitrary code", "xyz-ABC", "xyz-ABC"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := LanguageLocaleName(tt.locale)
if result != tt.expected {
t.Errorf("LanguageLocaleName(%q) = %q, want %q", tt.locale, result, tt.expected)
}
})
}
}
func TestResolveLanguage(t *testing.T) {
ctxWithLocale := context.WithValue(context.Background(), LanguageContextKey, "ko-KR")
tests := []struct {
name string
ctx context.Context
locale string
envLang string
expected string
}{
{"explicit locale wins over context", ctxWithLocale, "en-US", "", "en-US"},
{"blank locale falls back to context", ctxWithLocale, "", "", "ko-KR"},
{"whitespace locale falls back to context", ctxWithLocale, " ", "", "ko-KR"},
{"no locale and no context falls back to default", context.Background(), "", "", "zh-CN"},
{"empty context value falls back to default", context.WithValue(
context.Background(), LanguageContextKey, ""), "", "", "zh-CN"},
{"deployment override wins over hardcoded default", context.Background(), "", "ru-RU", "ru-RU"},
{"explicit locale wins over deployment override", context.Background(), "ja-JP", "ru-RU", "ja-JP"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envLang != "" {
t.Setenv("WEKNORA_LANGUAGE", tt.envLang)
}
if got := ResolveLanguage(tt.ctx, tt.locale); got != tt.expected {
t.Errorf("ResolveLanguage(_, %q) = %q, want %q", tt.locale, got, tt.expected)
}
})
}
}
// A missing language must never reach a prompt template: "Write in {{.Language}}"
// renders as "Write in ." and lets the model choose the output language.
func TestResolveLanguageNameNeverReturnsEmpty(t *testing.T) {
for _, ctx := range []context.Context{
context.Background(),
context.WithValue(context.Background(), LanguageContextKey, ""),
} {
if got := ResolveLanguageName(ctx, ""); got == "" {
t.Fatal("ResolveLanguageName returned an empty prompt language")
}
}
if got := ResolveLanguageName(context.Background(), "ko-KR"); got != "Korean" {
t.Errorf("ResolveLanguageName(_, %q) = %q, want %q", "ko-KR", got, "Korean")
}
}
// Wiki carries an already-resolved display name on each queued update, so
// re-resolving it downstream must be a no-op rather than a reset to default.
func TestResolveLanguageNameIsIdempotentOverDisplayNames(t *testing.T) {
for _, name := range []string{"Chinese (Simplified)", "English", "Korean"} {
if got := ResolveLanguageName(context.Background(), name); got != name {
t.Errorf("ResolveLanguageName(_, %q) = %q, want it unchanged", name, got)
}
}
}
func TestLanguageFromContextOrDefault(t *testing.T) {
if got := LanguageFromContextOrDefault(context.Background()); got != "zh-CN" {
t.Errorf("LanguageFromContextOrDefault(empty) = %q, want %q", got, "zh-CN")
}
ctx := context.WithValue(context.Background(), LanguageContextKey, "en-US")
if got := LanguageFromContextOrDefault(ctx); got != "en-US" {
t.Errorf("LanguageFromContextOrDefault(en-US) = %q, want %q", got, "en-US")
}
}
func TestMCPOAuthNonInteractive(t *testing.T) {
if IsMCPOAuthNonInteractive(nil) {
t.Fatal("nil context should not be non-interactive")
}
if IsMCPOAuthNonInteractive(context.Background()) {
t.Fatal("background context should not be non-interactive")
}
ctx := WithMCPOAuthNonInteractive(context.Background())
if !IsMCPOAuthNonInteractive(ctx) {
t.Fatal("marked context should be non-interactive")
}
child := context.WithValue(ctx, LanguageContextKey, "en-US")
if !IsMCPOAuthNonInteractive(child) {
t.Fatal("child context should inherit non-interactive flag")
}
}
func TestLanguageFromContext(t *testing.T) {
tests := []struct {
name string
setupCtx func() interface{}
expectValue string
expectOK bool
}{
{
name: "empty context",
setupCtx: func() interface{} {
return nil
},
expectValue: "",
expectOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// These are basic smoke tests
// Real context testing would require context.Context objects
if tt.setupCtx == nil {
t.Skip("skipping context-dependent test")
}
})
}
}
func TestLLMCallMetadataContext(t *testing.T) {
ctx := WithLLMCallMetadata(context.Background(), "wiki_page_modify", "abc123")
purpose, prefix := LLMCallMetadataFromContext(ctx)
if purpose != "wiki_page_modify" || prefix != "abc123" {
t.Fatalf("metadata = (%q, %q)", purpose, prefix)
}
}
func TestTaskRetryMetadataContext(t *testing.T) {
if _, _, ok := TaskRetryMetadataFromContext(nil); ok {
t.Fatal("nil context should not contain task retry metadata")
}
if _, _, ok := TaskRetryMetadataFromContext(context.Background()); ok {
t.Fatal("background context should not contain task retry metadata")
}
ctx := WithTaskRetryMetadata(context.Background(), 2, 3)
retried, maxRetry, ok := TaskRetryMetadataFromContext(ctx)
if !ok || retried != 2 || maxRetry != 3 {
t.Fatalf("retry metadata = (%d, %d, %v), want (2, 3, true)", retried, maxRetry, ok)
}
}
// BenchmarkLanguageLocaleName benchmarks the language name lookup
func BenchmarkLanguageLocaleName(b *testing.B) {
testCases := []string{"zh", "en", "zh-CN", "ko", "unknown"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, locale := range testCases {
LanguageLocaleName(locale)
}
}
}
+640
View File
@@ -0,0 +1,640 @@
package types
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
// BuiltinAgentID constants for built-in agents
const (
// BuiltinQuickAnswerID is the ID for the built-in quick answer (RAG) agent
BuiltinQuickAnswerID = "builtin-quick-answer"
// BuiltinSmartReasoningID is the ID for the built-in smart reasoning (ReAct) agent
BuiltinSmartReasoningID = "builtin-smart-reasoning"
// BuiltinDeepResearcherID is the ID for the built-in deep researcher agent
BuiltinDeepResearcherID = "builtin-deep-researcher"
// BuiltinDataAnalystID is the ID for the built-in data analyst agent
BuiltinDataAnalystID = "builtin-data-analyst"
// BuiltinKnowledgeGraphExpertID is the ID for the built-in knowledge graph expert agent
BuiltinKnowledgeGraphExpertID = "builtin-knowledge-graph-expert"
// BuiltinDocumentAssistantID is the ID for the built-in document assistant agent
BuiltinDocumentAssistantID = "builtin-document-assistant"
// BuiltinWikiResearcherID is the ID for the built-in wiki researcher agent
BuiltinWikiResearcherID = "builtin-wiki-researcher"
// BuiltinWikiFixerID is the ID for the built-in wiki fixer agent
BuiltinWikiFixerID = "builtin-wiki-fixer"
// BuiltinSkillInstallerID is the ID for the built-in skill installer agent
BuiltinSkillInstallerID = "builtin-skill-installer"
)
// AgentMode constants for agent running mode
const (
// AgentModeQuickAnswer is the RAG mode for quick Q&A
AgentModeQuickAnswer = "quick-answer"
// AgentModeSmartReasoning is the ReAct mode for multi-step reasoning
AgentModeSmartReasoning = "smart-reasoning"
)
// AgentType constants for Smart-Reasoning agent presets.
// These presets bundle a recommended system prompt template,
// tool allowlist, KB compatibility hint, and other defaults so users
// don't have to configure everything from scratch.
// AgentTypeCustom means the user wants full control and we won't
// auto-fill anything based on the preset.
const (
// AgentTypeRAGQA prefers vector/keyword chunk retrieval on document KBs.
AgentTypeRAGQA = "rag-qa"
// AgentTypeWikiQA prefers wiki-page navigation on wiki-enabled KBs.
AgentTypeWikiQA = "wiki-qa"
// AgentTypeHybridRAGWiki orchestrates Wiki + RAG on KBs where both are enabled.
AgentTypeHybridRAGWiki = "hybrid-rag-wiki"
// AgentTypeDataAnalysis runs SQL / statistics over tabular files (CSV, Excel)
// uploaded into the KB. Retrieval semantics (vector/wiki/…) are largely
// irrelevant — this type is about data_schema + data_analysis tools.
AgentTypeDataAnalysis = "data-analysis"
// AgentTypeCustom is the "no preset" option; user-configured end to end.
AgentTypeCustom = "custom"
)
// CustomAgent represents a configurable AI agent (similar to GPTs)
type CustomAgent struct {
// Unique identifier of the agent (composite primary key with TenantID)
// For built-in agents, this is 'builtin-quick-answer' or 'builtin-smart-reasoning'
// For custom agents, this is a UUID
ID string `yaml:"id" json:"id" gorm:"type:varchar(36);primaryKey"`
// Name of the agent
Name string `yaml:"name" json:"name" gorm:"type:varchar(255);not null"`
// Description of the agent
Description string `yaml:"description" json:"description" gorm:"type:text"`
// Avatar/Icon of the agent (emoji or icon name)
Avatar string `yaml:"avatar" json:"avatar" gorm:"type:varchar(64)"`
// Whether this is a built-in agent (normal mode / agent mode)
IsBuiltin bool `yaml:"is_builtin" json:"is_builtin" gorm:"default:false"`
// Tenant ID (composite primary key with ID)
TenantID uint64 `yaml:"tenant_id" json:"tenant_id" gorm:"primaryKey"`
// Created by user ID
CreatedBy string `yaml:"created_by" json:"created_by" gorm:"type:varchar(36)"`
Visibility string `yaml:"visibility" json:"visibility" gorm:"type:varchar(16);not null"`
Enabled bool `yaml:"enabled" json:"enabled" gorm:"not null"`
// Agent configuration
Config CustomAgentConfig `yaml:"config" json:"config" gorm:"type:json"`
// Timestamps
CreatedAt time.Time `yaml:"created_at" json:"created_at"`
UpdatedAt time.Time `yaml:"updated_at" json:"updated_at"`
DeletedAt gorm.DeletedAt `yaml:"deleted_at" json:"deleted_at" gorm:"index"`
// CreatorName 由 list handler 在返回前批量回填,作用同 KnowledgeBase.CreatorName:
// 让前端列表卡片区分「我创建」与「同空间其他成员创建」。不落库,内建 agent / 老数据
// 仍可能为空。
CreatorName string `yaml:"-" json:"creator_name,omitempty" gorm:"-"`
DigitalHuman *AgentDigitalHumanSummary `yaml:"-" json:"digital_human,omitempty" gorm:"-"`
}
type AgentDigitalHumanSummary struct {
ID string `json:"id"`
Name string `json:"name"`
AppliedVersion int `json:"applied_version"`
ActiveVersion int `json:"active_version"`
}
// CustomAgentConfig represents the configuration of a custom agent
type CustomAgentConfig struct {
// ===== Basic Settings =====
Role string `yaml:"role" json:"role"`
ContentTags []string `yaml:"content_tags" json:"content_tags"`
FusionAgentIDs []string `yaml:"fusion_agent_ids" json:"fusion_agent_ids"`
// Agent mode: "quick-answer" for RAG mode, "smart-reasoning" for ReAct agent mode
AgentMode string `yaml:"agent_mode" json:"agent_mode"`
// AgentType is a preset category under smart-reasoning mode that pre-fills
// system prompt, allowed tools and recommended KB compatibility.
// Valid values: "rag-qa", "wiki-qa", "hybrid-rag-wiki", "custom".
// Empty / unknown values are treated as "custom" (no preset applied).
// Ignored for quick-answer mode.
AgentType string `yaml:"agent_type" json:"agent_type,omitempty"`
// System prompt for the agent (unified prompt, uses web_search_status placeholder for dynamic behavior)
SystemPrompt string `yaml:"system_prompt" json:"system_prompt"`
// SystemPromptID references a template ID in prompt_templates/ YAML files.
// If set and SystemPrompt is empty, the template content will be resolved at startup.
SystemPromptID string `yaml:"system_prompt_id" json:"system_prompt_id,omitempty"`
// Context template for normal mode (how to format retrieved chunks)
ContextTemplate string `yaml:"context_template" json:"context_template"`
// ContextTemplateID references a template ID in prompt_templates/ YAML files.
// If set and ContextTemplate is empty, the template content will be resolved at startup.
ContextTemplateID string `yaml:"context_template_id" json:"context_template_id,omitempty"`
// ===== Model Settings =====
// Model ID to use for conversations
ModelID string `yaml:"model_id" json:"model_id"`
// ReRank model ID for retrieval
RerankModelID string `yaml:"rerank_model_id" json:"rerank_model_id"`
// Temperature for LLM (0-1)
Temperature float64 `yaml:"temperature" json:"temperature"`
// Maximum completion tokens. Quick-answer uses this for the RAG answer.
// Smart-reasoning ReAct rounds send this value as-is (zero becomes
// DefaultMaxCompletionTokens at call time: 4096, or 24576 with a sandbox).
MaxCompletionTokens int `yaml:"max_completion_tokens" json:"max_completion_tokens"`
// Whether to enable thinking mode (for models that support extended thinking)
Thinking *bool `yaml:"thinking" json:"thinking"`
// Whether final answers include knowledge/web source citations. Nil defaults to true
// so agents saved before this option was introduced keep their existing behavior.
CitationEnabled *bool `yaml:"citation_enabled" json:"citation_enabled"`
// ===== Agent Mode Settings =====
// Maximum iterations for the ReAct loop. Zero is unset (filled with a
// default). A negative value is unlimited: the loop runs until the model
// stops, the user cancels, or another guard fires.
MaxIterations int `yaml:"max_iterations" json:"max_iterations"`
// Timeout for a single LLM call in seconds (0 = use global default)
LLMCallTimeout int `yaml:"llm_call_timeout" json:"llm_call_timeout,omitempty"`
// Allowed tools (only for agent type)
AllowedTools []string `yaml:"allowed_tools" json:"allowed_tools"`
// MCP service selection mode: "all" = all enabled MCP services, "selected" = specific services, "none" = no MCP
MCPSelectionMode string `yaml:"mcp_selection_mode" json:"mcp_selection_mode"`
// Selected MCP service IDs (only used when MCPSelectionMode is "selected")
MCPServices []string `yaml:"mcp_services" json:"mcp_services"`
// MCPAuthWaitTimeout is how many seconds to wait for in-conversation OAuth
// authorization before skipping. <=0 uses the gate's configured timeout.
MCPAuthWaitTimeout int `yaml:"mcp_auth_wait_timeout,omitempty" json:"mcp_auth_wait_timeout,omitempty"`
// ===== Skills Settings (only for smart-reasoning mode) =====
// Skills selection mode: "all" = all preloaded skills, "selected" = specific skills, "none" = no skills
SkillsSelectionMode string `yaml:"skills_selection_mode" json:"skills_selection_mode"`
// Selected skill names (only used when SkillsSelectionMode is "selected")
SelectedSkills []string `yaml:"selected_skills" json:"selected_skills"`
// InstructionSkillIDs are workspace Markdown rules applied in both chat
// modes. They grant no script/tool permissions and need no sandbox image.
InstructionSkillIDs []string `yaml:"instruction_skill_ids,omitempty" json:"instruction_skill_ids,omitempty"`
// ===== Sandbox Settings =====
// SandboxConfigID selects which workspace sandbox config this agent's
// skill scripts run on. Empty means sandbox execution is disabled.
//
// This references the LOGICAL config, never a specific revision: keeping
// the indirection here is what would let credential rotation happen
// without re-pointing every agent (see the spec's §4.8).
SandboxConfigID string `yaml:"sandbox_config_id" json:"sandbox_config_id,omitempty"`
// ===== Knowledge Base Settings =====
// Knowledge base selection mode: "all" = all KBs, "selected" = specific KBs, "none" = no KB
KBSelectionMode string `yaml:"kb_selection_mode" json:"kb_selection_mode"`
// Associated knowledge base IDs (only used when KBSelectionMode is "selected")
KnowledgeBases []string `yaml:"knowledge_bases" json:"knowledge_bases"`
// Whether to retrieve knowledge base only when explicitly mentioned with @ (default: false)
// When true, knowledge base retrieval only happens if user explicitly mentions KB/files with @
// When false, knowledge base retrieval happens according to KBSelectionMode
RetrieveKBOnlyWhenMentioned bool `yaml:"retrieve_kb_only_when_mentioned" json:"retrieve_kb_only_when_mentioned"`
// Whether to retain retrieval history across turns
RetainRetrievalHistory bool `yaml:"retain_retrieval_history" json:"retain_retrieval_history"`
// ===== Image Upload / Multimodal Settings =====
// Whether image upload is enabled for this agent (default: false)
ImageUploadEnabled bool `yaml:"image_upload_enabled" json:"image_upload_enabled"`
// VLM model ID for image analysis (optional, falls back to workspace-level VLM)
VLMModelID string `yaml:"vlm_model_id" json:"vlm_model_id"`
// Whether audio upload (ASR transcription) is enabled for this agent (default: false)
AudioUploadEnabled bool `yaml:"audio_upload_enabled" json:"audio_upload_enabled"`
// ASR model ID for audio transcription (optional)
ASRModelID string `yaml:"asr_model_id" json:"asr_model_id"`
// Storage provider for image uploads: "local", "minio", "cos", "tos", "s3", "oss", "ks3".
// Empty means use the global/workspace default provider.
ImageStorageProvider string `yaml:"image_storage_provider" json:"image_storage_provider"`
// ===== File Type Restriction Settings =====
// Supported file types for this agent (e.g., ["csv", "xlsx", "xls"])
// Empty means all file types are supported
// When set, only files with matching extensions can be used with this agent
SupportedFileTypes []string `yaml:"supported_file_types" json:"supported_file_types"`
// ===== Chat Attachment Parsing Settings =====
// ChatParserEngineRules selects parser engines for session-scoped chat
// attachments by file type. Takes precedence over the tenant-level
// ParserEngineConfig.ChatParserEngineRules; an explicit per-request
// parser_engine still overrides both.
ChatParserEngineRules []ParserEngineRule `yaml:"chat_parser_engine_rules" json:"chat_parser_engine_rules,omitempty"`
// AttachmentImageUnderstanding enables VLM OCR fallback for image-only /
// scanned documents (PDF/PPT whose pages are images). Disabled by default
// because it materially increases parse latency; only triggers when the
// extracted text is below a threshold and a VLM model is configured.
AttachmentImageUnderstanding bool `yaml:"attachment_image_understanding" json:"attachment_image_understanding"`
// AttachmentOCRMaxPages caps how many pages of a scanned / image-only
// document this agent sends to the VLM for OCR. 0 falls back to the global
// default (WEKNORA_CHAT_ATTACHMENT_OCR_MAX_PAGES). More pages means higher
// coverage but slower parsing and more VLM cost.
AttachmentOCRMaxPages int `yaml:"attachment_ocr_max_pages" json:"attachment_ocr_max_pages,omitempty"`
// AttachmentParseWaitTimeoutSec bounds, in seconds, how long a chat turn
// waits for this agent's still-parsing attachments before proceeding with
// only the finished ones. 0 falls back to the global default
// (WEKNORA_CHAT_ATTACHMENT_WAIT_TIMEOUT_SEC).
AttachmentParseWaitTimeoutSec int `yaml:"attachment_parse_wait_timeout_sec" json:"attachment_parse_wait_timeout_sec,omitempty"`
// ===== Data Analysis Settings =====
// Whether to run the legacy in-pipeline DuckDB SQL data-analysis stage when
// the retrieved chunks include CSV/Excel files. This issues an extra LLM
// call to generate a SQL query and is disabled by default because most
// quick-answer / RAG-style agents do not want the added latency.
DataAnalysisEnabled bool `yaml:"data_analysis_enabled" json:"data_analysis_enabled"`
// ===== FAQ Strategy Settings =====
// Whether FAQ priority strategy is enabled (FAQ answers prioritized over document chunks)
FAQPriorityEnabled bool `yaml:"faq_priority_enabled" json:"faq_priority_enabled"`
// FAQ direct answer threshold - if similarity > this value, use FAQ answer directly
FAQDirectAnswerThreshold float64 `yaml:"faq_direct_answer_threshold" json:"faq_direct_answer_threshold"`
// FAQ score boost multiplier - FAQ results score multiplied by this factor
FAQScoreBoost float64 `yaml:"faq_score_boost" json:"faq_score_boost"`
// ===== Web Search Settings =====
// Whether web search is enabled
WebSearchEnabled bool `yaml:"web_search_enabled" json:"web_search_enabled"`
// Maximum web search results
WebSearchMaxResults int `yaml:"web_search_max_results" json:"web_search_max_results"`
// WebSearchProviderID references a specific WebSearchProviderEntity.
// If empty, the workspace's default provider (is_default=true) is used.
WebSearchProviderID string `yaml:"web_search_provider_id" json:"web_search_provider_id,omitempty"`
// Whether to auto-fetch full page content for reranked web search results
WebFetchEnabled bool `yaml:"web_fetch_enabled" json:"web_fetch_enabled"`
// Max number of pages to fetch after rerank (default: 3)
WebFetchTopN int `yaml:"web_fetch_top_n" json:"web_fetch_top_n,omitempty"`
// ===== Multi-turn Conversation Settings =====
// Whether multi-turn conversation is enabled
MultiTurnEnabled bool `yaml:"multi_turn_enabled" json:"multi_turn_enabled"`
// Number of history turns to keep in context
HistoryTurns int `yaml:"history_turns" json:"history_turns"`
// Whether this agent may read the user's long-term memory. Nil inherits
// the workspace setting; false opts a single agent out of memory even when
// the workspace has it on. There is no "on" that overrides the workspace.
MemoryEnabled *bool `yaml:"memory_enabled" json:"memory_enabled,omitempty"`
// ===== Retrieval Strategy Settings (for both modes) =====
// Embedding/Vector retrieval top K
EmbeddingTopK int `yaml:"embedding_top_k" json:"embedding_top_k"`
// Keyword retrieval threshold
KeywordThreshold float64 `yaml:"keyword_threshold" json:"keyword_threshold"`
// Vector retrieval threshold
VectorThreshold float64 `yaml:"vector_threshold" json:"vector_threshold"`
// Rerank top K
RerankTopK int `yaml:"rerank_top_k" json:"rerank_top_k"`
// Rerank threshold
RerankThreshold float64 `yaml:"rerank_threshold" json:"rerank_threshold"`
// ===== Advanced Settings (mainly for normal mode) =====
// Whether to enable query expansion
EnableQueryExpansion bool `yaml:"enable_query_expansion" json:"enable_query_expansion"`
// Whether to enable query rewrite for multi-turn conversations
EnableRewrite bool `yaml:"enable_rewrite" json:"enable_rewrite"`
// Rewrite prompt system message
RewritePromptSystem string `yaml:"rewrite_prompt_system" json:"rewrite_prompt_system"`
// Rewrite prompt user message template
RewritePromptUser string `yaml:"rewrite_prompt_user" json:"rewrite_prompt_user"`
// Dedicated chat model ID for the query-understanding (rewrite + intent) step.
// When empty, the main conversation ModelID is used as a fallback.
QueryUnderstandModelID string `yaml:"query_understand_model_id" json:"query_understand_model_id,omitempty"`
// Fallback strategy: "fixed" for fixed response, "model" for model generation
FallbackStrategy string `yaml:"fallback_strategy" json:"fallback_strategy"`
// Fixed fallback response (when FallbackStrategy is "fixed")
FallbackResponse string `yaml:"fallback_response" json:"fallback_response"`
// Fallback prompt (when FallbackStrategy is "model")
FallbackPrompt string `yaml:"fallback_prompt" json:"fallback_prompt"`
// IntentPrompts holds per-intent system prompt overrides for non-retrieval
// intents (greeting, chitchat, etc.). Empty values fall back to templates
// under config/prompt_templates/intent_prompts.yaml.
IntentPrompts map[string]string `yaml:"intent_prompts" json:"intent_prompts,omitempty"`
// ===== Conversation Question Suggestions =====
// QuestionSuggestions owns both the static/knowledge-backed prompts shown
// before the first user turn and the contextual follow-up questions shown
// after a completed assistant answer.
QuestionSuggestions *QuestionSuggestionConfig `yaml:"question_suggestions,omitempty" json:"question_suggestions,omitempty"`
}
const (
SuggestionModeCurated = "curated"
SuggestionModeKnowledge = "knowledge"
SuggestionModeGenerated = "generated"
SuggestionModeHybrid = "hybrid"
SuggestionCategoryClarify = "clarify"
SuggestionCategoryDeepen = "deepen"
SuggestionCategoryAction = "action"
)
// QuestionSuggestionConfig is the agent-owned configuration for question
// suggestions. Channel settings may suppress rendering, but never override
// this content/generation policy.
type QuestionSuggestionConfig struct {
Starters StarterSuggestionConfig `yaml:"starters" json:"starters"`
FollowUps FollowUpSuggestionConfig `yaml:"follow_ups" json:"follow_ups"`
}
// StarterSuggestionConfig controls prompts shown before the first user turn.
type StarterSuggestionConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Mode string `yaml:"mode" json:"mode"`
Items []string `yaml:"items" json:"items"`
Count int `yaml:"count" json:"count"`
}
// FollowUpSuggestionConfig controls contextual questions generated after a
// completed assistant answer.
type FollowUpSuggestionConfig struct {
Enabled bool `yaml:"enabled" json:"enabled"`
Mode string `yaml:"mode" json:"mode"`
Count int `yaml:"count" json:"count"`
ModelID string `yaml:"model_id,omitempty" json:"model_id,omitempty"`
AdditionalInstruction string `yaml:"additional_instruction,omitempty" json:"additional_instruction,omitempty"`
Categories []string `yaml:"categories,omitempty" json:"categories,omitempty"`
MaxContextTurns int `yaml:"max_context_turns" json:"max_context_turns"`
SuppressOnFallback bool `yaml:"suppress_on_fallback" json:"suppress_on_fallback"`
SuppressWhenAnswerAsksQuestion bool `yaml:"suppress_when_answer_asks_question" json:"suppress_when_answer_asks_question"`
KnowledgeFallback bool `yaml:"knowledge_fallback" json:"knowledge_fallback"`
AllowRegenerate bool `yaml:"allow_regenerate" json:"allow_regenerate"`
}
// EnsureDefaults normalizes suggestion configuration without overriding
// explicit enable/disable choices.
func (c *QuestionSuggestionConfig) EnsureDefaults() {
if c.Starters.Mode == "" {
c.Starters.Mode = SuggestionModeHybrid
}
if c.Starters.Count <= 0 {
c.Starters.Count = 6
}
if c.Starters.Items == nil {
c.Starters.Items = []string{}
}
if c.FollowUps.Mode == "" {
c.FollowUps.Mode = SuggestionModeHybrid
}
if c.FollowUps.Count <= 0 {
c.FollowUps.Count = 3
}
if c.FollowUps.MaxContextTurns <= 0 {
c.FollowUps.MaxContextTurns = 2
}
if len(c.FollowUps.Categories) == 0 {
c.FollowUps.Categories = []string{
SuggestionCategoryClarify,
SuggestionCategoryDeepen,
SuggestionCategoryAction,
}
}
}
// Validate rejects invalid agent-authored suggestion settings before they are
// persisted or used to incur a model call.
func (c *QuestionSuggestionConfig) Validate() error {
if c == nil {
return nil
}
if c.Starters.Count < 1 || c.Starters.Count > 8 {
return fmt.Errorf("starter suggestion count must be between 1 and 8")
}
if c.FollowUps.Count < 1 || c.FollowUps.Count > 5 {
return fmt.Errorf("follow-up suggestion count must be between 1 and 5")
}
if c.FollowUps.MaxContextTurns < 1 || c.FollowUps.MaxContextTurns > 5 {
return fmt.Errorf("follow-up max_context_turns must be between 1 and 5")
}
if !oneOf(c.Starters.Mode, SuggestionModeCurated, SuggestionModeKnowledge, SuggestionModeHybrid) {
return fmt.Errorf("invalid starter suggestion mode %q", c.Starters.Mode)
}
if !oneOf(c.FollowUps.Mode, SuggestionModeGenerated, SuggestionModeKnowledge, SuggestionModeHybrid) {
return fmt.Errorf("invalid follow-up suggestion mode %q", c.FollowUps.Mode)
}
for i, item := range c.Starters.Items {
trimmed := strings.TrimSpace(item)
if trimmed == "" {
return fmt.Errorf("starter suggestion %d cannot be empty", i+1)
}
if len([]rune(trimmed)) > 200 {
return fmt.Errorf("starter suggestion %d exceeds 200 characters", i+1)
}
}
if len([]rune(strings.TrimSpace(c.FollowUps.AdditionalInstruction))) > 2000 {
return fmt.Errorf("follow-up additional_instruction exceeds 2000 characters")
}
for _, category := range c.FollowUps.Categories {
if !oneOf(category, SuggestionCategoryClarify, SuggestionCategoryDeepen, SuggestionCategoryAction) {
return fmt.Errorf("invalid follow-up suggestion category %q", category)
}
}
return nil
}
func oneOf(value string, allowed ...string) bool {
for _, candidate := range allowed {
if value == candidate {
return true
}
}
return false
}
// ResolveChatParserEngine returns the agent-configured parser engine for a
// chat attachment file type, or the type-level default when no rule matches.
// Mirrors ParserEngineConfig.ResolveChatParserEngine.
func (c *CustomAgentConfig) ResolveChatParserEngine(fileType string) string {
if c != nil {
normalized := normalizeParserFileType(fileType)
for _, rule := range c.ChatParserEngineRules {
for _, candidate := range rule.FileTypes {
if normalizeParserFileType(candidate) == normalized {
return strings.TrimSpace(rule.Engine)
}
}
}
}
return DefaultParserEngine(fileType)
}
// Value implements driver.Valuer interface for CustomAgentConfig
func (c CustomAgentConfig) Value() (driver.Value, error) {
return json.Marshal(c)
}
// Scan implements sql.Scanner interface for CustomAgentConfig
func (c *CustomAgentConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
var b []byte
switch v := value.(type) {
case []byte:
b = v
case string:
b = []byte(v)
default:
return nil
}
return json.Unmarshal(b, c)
}
// TableName returns the table name for CustomAgent
func (CustomAgent) TableName() string {
return "custom_agents"
}
// BeforeCreate supplies lifecycle defaults for native repository-created Agents.
func (a *CustomAgent) BeforeCreate(_ *gorm.DB) error {
if a.Visibility == "" {
a.Visibility = "global"
a.Enabled = true
}
return nil
}
// EnsureDefaults sets default values for the agent
func (a *CustomAgent) EnsureDefaults() {
if a == nil {
return
}
// Empty visibility identifies legacy callers and registry definitions.
// Persisted lifecycle fields always have a nonempty visibility value.
if a.Visibility == "" {
a.Visibility = "global"
a.Enabled = true
}
if a.Config.QuestionSuggestions == nil {
a.Config.QuestionSuggestions = &QuestionSuggestionConfig{
Starters: StarterSuggestionConfig{
Enabled: true,
Mode: SuggestionModeHybrid,
Items: []string{},
Count: 6,
},
FollowUps: FollowUpSuggestionConfig{
Enabled: false,
Mode: SuggestionModeHybrid,
Count: 3,
MaxContextTurns: 2,
SuppressOnFallback: true,
SuppressWhenAnswerAsksQuestion: true,
KnowledgeFallback: true,
Categories: []string{
SuggestionCategoryClarify,
SuggestionCategoryDeepen,
SuggestionCategoryAction,
},
},
}
} else {
a.Config.QuestionSuggestions.EnsureDefaults()
}
if a.Config.Temperature < 0 {
a.Config.Temperature = 0.7
}
if a.Config.MaxIterations == 0 {
a.Config.MaxIterations = 10
}
if a.Config.MaxIterations < 0 {
a.Config.MaxIterations = UnlimitedMaxIterations
}
if a.Config.WebSearchMaxResults == 0 {
a.Config.WebSearchMaxResults = 5
}
if a.Config.HistoryTurns == 0 {
a.Config.HistoryTurns = 5
}
// Retrieval strategy defaults
if a.Config.EmbeddingTopK == 0 {
a.Config.EmbeddingTopK = 10
}
if a.Config.KeywordThreshold == 0 {
a.Config.KeywordThreshold = 0.3
}
if a.Config.VectorThreshold == 0 {
a.Config.VectorThreshold = 0.5
}
if a.Config.RerankTopK == 0 {
a.Config.RerankTopK = 5
}
// Advanced settings defaults
if a.Config.FallbackStrategy == "" {
a.Config.FallbackStrategy = "model"
}
// MaxCompletionTokens 0 means "use DefaultMaxCompletionTokens at call
// time". Do not materialize a number here — that would make the editor
// treat a chosen default as a custom cap.
// Agent mode should always enable multi-turn conversation
if a.Config.AgentMode == AgentModeSmartReasoning {
a.Config.MultiTurnEnabled = true
}
// Pin thinking to an explicit false when unset so provider-specific wire
// formats (e.g. thinking_control=thinking_type) always receive a value.
if a.Config.Thinking == nil {
disabled := false
a.Config.Thinking = &disabled
}
// Keep citations enabled for existing agents whose persisted config predates
// this field. An explicit false is always preserved.
if a.Config.CitationEnabled == nil {
enabled := true
a.Config.CitationEnabled = &enabled
}
}
// IsAgentMode returns true if this agent uses ReAct agent mode
func (a *CustomAgent) IsAgentMode() bool {
return a.Config.AgentMode == AgentModeSmartReasoning
}
// SuggestedQuestion 推荐问题
type SuggestedQuestion struct {
// 问题文本
Question string `json:"question"`
// 来源类型: "agent_config", "faq", "document", "wiki"
Source string `json:"source"`
// 来源知识库ID(仅 faq/document/wiki 来源时有值)
KnowledgeBaseID string `json:"knowledge_base_id,omitempty"`
}
// BuiltinAgentRegistry provides a registry of all built-in agents.
// It is initialised empty and populated by LoadBuiltinAgentsConfig from
// config/builtin_agents.yaml at startup via rebuildRegistryFromConfig.
var BuiltinAgentRegistry = map[string]func(uint64) *CustomAgent{}
// builtinAgentIDsOrdered defines the fixed display order of built-in agents
// that are exposed in the user-facing agent list (ListAgents).
//
// NOTE: BuiltinWikiFixerID and BuiltinSkillInstallerID are intentionally
// excluded here. Both are internal agents invoked programmatically — the wiki
// fixer from the Wiki editor, the skill installer from the sandbox-config skill
// upload flow — and should not clutter the tenant's agent picker. They remain
// fully usable via GetAgentByID because the YAML entries still register them in
// BuiltinAgentRegistry.
var builtinAgentIDsOrdered = []string{
BuiltinQuickAnswerID,
BuiltinSmartReasoningID,
BuiltinWikiResearcherID,
BuiltinDeepResearcherID,
BuiltinDataAnalystID,
BuiltinKnowledgeGraphExpertID,
BuiltinDocumentAssistantID,
}
// GetBuiltinAgentIDs returns all built-in agent IDs in fixed order
func GetBuiltinAgentIDs() []string {
return builtinAgentIDsOrdered
}
// IsBuiltinAgentID checks if the given ID is a built-in agent ID
func IsBuiltinAgentID(id string) bool {
_, exists := BuiltinAgentRegistry[id]
return exists
}
// GetBuiltinAgent returns a built-in agent by ID, or nil if not found
func GetBuiltinAgent(id string, tenantID uint64) *CustomAgent {
if factory, exists := BuiltinAgentRegistry[id]; exists {
return factory(tenantID)
}
return nil
}
+108
View File
@@ -0,0 +1,108 @@
package types
import "testing"
func TestCustomAgentConfigResolveChatParserEngine(t *testing.T) {
config := &CustomAgentConfig{ChatParserEngineRules: []ParserEngineRule{
{FileTypes: []string{"pdf", ".pptx"}, Engine: "mineru"},
{FileTypes: []string{"png", "jpg"}, Engine: "paddleocr_vl"},
}}
for input, expected := range map[string]string{
"PDF": "mineru", ".pptx": "mineru", "png": "paddleocr_vl",
"txt": "", "ppt": "markitdown",
} {
if actual := config.ResolveChatParserEngine(input); actual != expected {
t.Fatalf("ResolveChatParserEngine(%q) = %q, want %q", input, actual, expected)
}
}
var nilConfig *CustomAgentConfig
if actual := nilConfig.ResolveChatParserEngine("pdf"); actual != "" {
t.Fatalf("nil config resolved %q", actual)
}
if actual := nilConfig.ResolveChatParserEngine("pptx"); actual != "markitdown" {
t.Fatalf("nil config pptx resolved %q, want markitdown", actual)
}
}
func TestEnsureDefaults_ThinkingExplicitFalse(t *testing.T) {
agent := &CustomAgent{Config: CustomAgentConfig{}}
agent.EnsureDefaults()
if agent.Config.Thinking == nil {
t.Fatal("EnsureDefaults should set Thinking to explicit false when unset")
}
if *agent.Config.Thinking {
t.Fatal("default Thinking should be false")
}
}
func TestEnsureDefaults_ThinkingPreservesTrue(t *testing.T) {
enabled := true
agent := &CustomAgent{Config: CustomAgentConfig{Thinking: &enabled}}
agent.EnsureDefaults()
if agent.Config.Thinking == nil || !*agent.Config.Thinking {
t.Fatal("EnsureDefaults must not overwrite an explicit Thinking=true")
}
}
func TestEnsureDefaults_MaxCompletionTokensByMode(t *testing.T) {
qa := &CustomAgent{Config: CustomAgentConfig{AgentMode: AgentModeQuickAnswer}}
qa.EnsureDefaults()
if qa.Config.MaxCompletionTokens != 0 {
t.Fatalf("unset max_completion_tokens must stay 0 (follow default at call time), got %d",
qa.Config.MaxCompletionTokens)
}
sr := &CustomAgent{Config: CustomAgentConfig{AgentMode: AgentModeSmartReasoning}}
sr.EnsureDefaults()
if sr.Config.MaxCompletionTokens != 0 {
t.Fatalf("unset smart-reasoning max_completion_tokens must stay 0, got %d",
sr.Config.MaxCompletionTokens)
}
explicit := &CustomAgent{Config: CustomAgentConfig{
AgentMode: AgentModeSmartReasoning,
MaxCompletionTokens: 64000,
}}
explicit.EnsureDefaults()
if explicit.Config.MaxCompletionTokens != 64000 {
t.Fatalf("EnsureDefaults must preserve explicit MaxCompletionTokens, got %d",
explicit.Config.MaxCompletionTokens)
}
}
func TestEnsureDefaults_MaxIterationsUnlimited(t *testing.T) {
unset := &CustomAgent{Config: CustomAgentConfig{}}
unset.EnsureDefaults()
if unset.Config.MaxIterations != 10 {
t.Fatalf("unset max_iterations should default to 10, got %d", unset.Config.MaxIterations)
}
unlimited := &CustomAgent{Config: CustomAgentConfig{MaxIterations: -1}}
unlimited.EnsureDefaults()
if unlimited.Config.MaxIterations != UnlimitedMaxIterations {
t.Fatalf("EnsureDefaults must preserve unlimited max_iterations, got %d",
unlimited.Config.MaxIterations)
}
oddNegative := &CustomAgent{Config: CustomAgentConfig{MaxIterations: -7}}
oddNegative.EnsureDefaults()
if oddNegative.Config.MaxIterations != UnlimitedMaxIterations {
t.Fatalf("any negative max_iterations should normalize to %d, got %d",
UnlimitedMaxIterations, oddNegative.Config.MaxIterations)
}
}
func TestEnsureDefaults_CitationsDefaultEnabledAndPreserveFalse(t *testing.T) {
legacy := &CustomAgent{Config: CustomAgentConfig{}}
legacy.EnsureDefaults()
if legacy.Config.CitationEnabled == nil || !*legacy.Config.CitationEnabled {
t.Fatal("legacy agents must default citation output to enabled")
}
disabled := false
explicit := &CustomAgent{Config: CustomAgentConfig{CitationEnabled: &disabled}}
explicit.EnsureDefaults()
if explicit.Config.CitationEnabled == nil || *explicit.Config.CitationEnabled {
t.Fatal("EnsureDefaults must preserve explicit citation_enabled=false")
}
}
+11
View File
@@ -0,0 +1,11 @@
package types
// QAPair represents a complete QA example with question, related passages and answer
type QAPair struct {
QID int // Question ID
Question string // Question text
PIDs []int // Related passage IDs
Passages []string // Passage texts
AID int // Answer ID
Answer string // Answer text
}
+652
View File
@@ -0,0 +1,652 @@
package types
import (
"encoding/json"
"log"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/utils"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Data source types and constants
const (
// Connector types
ConnectorTypeFeishu = "feishu"
// ConnectorTypeLark is Feishu's international edition (open.larksuite.com).
// It shares the Feishu connector; only the API host and tenant differ.
ConnectorTypeLark = "lark"
// ConnectorTypeFeishuDrive is the Feishu Drive (云盘) mode: syncs documents
// under a user-supplied Drive folder_token, as opposed to a Wiki space.
// Shares the feishu connector package; only resource enumeration + fetch differ.
ConnectorTypeFeishuDrive = "feishu_drive"
// ConnectorTypeLarkDrive is the Lark (international) Drive mode, the
// international counterpart of ConnectorTypeFeishuDrive.
ConnectorTypeLarkDrive = "lark_drive"
ConnectorTypeNotion = "notion"
ConnectorTypeConfluence = "confluence"
ConnectorTypeYuque = "yuque"
ConnectorTypeGitHub = "github"
ConnectorTypeGoogleDrive = "google_drive"
ConnectorTypeOneDrive = "onedrive"
ConnectorTypeDingTalk = "dingtalk"
ConnectorTypeWebCrawler = "web_crawler"
ConnectorTypeSlack = "slack"
ConnectorTypeIMAP = "imap"
ConnectorTypeRSS = "rss"
ConnectorTypeGitLab = "gitlab"
ConnectorTypeIMA = "ima"
// Sync modes
SyncModeIncremental = "incremental"
SyncModeFull = "full"
// Data source status
DataSourceStatusActive = "active"
DataSourceStatusPaused = "paused"
DataSourceStatusError = "error"
DataSourceStatusDeleted = "deleted"
// Sync log status
SyncLogStatusRunning = "running"
SyncLogStatusSuccess = "success"
SyncLogStatusPartial = "partial"
SyncLogStatusFailed = "failed"
SyncLogStatusCanceled = "canceled"
// Conflict resolution strategies
ConflictStrategyOverwrite = "overwrite"
ConflictStrategySkip = "skip"
)
// DataSource represents a configured external data source for synchronization
type DataSource struct {
// Unique identifier
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// Workspace ID for multi-workspace isolation
TenantID uint64 `json:"tenant_id" gorm:"index"`
// Target knowledge base ID
KnowledgeBaseID string `json:"knowledge_base_id" gorm:"index"`
// User-friendly name
Name string `json:"name"`
// Connector type (feishu, notion, confluence, etc.)
Type string `json:"type" gorm:"type:varchar(50);index"`
// Encrypted configuration (API credentials, tokens, etc.)
// Stored as JSON with AES-256-GCM encryption
Config JSON `json:"config" gorm:"type:jsonb"`
// Cron expression for scheduled syncs (e.g., "0 */6 * * *" = every 6 hours)
SyncSchedule string `json:"sync_schedule"`
// Sync mode: "incremental" (recommended) or "full"
SyncMode string `json:"sync_mode" gorm:"type:varchar(20);default:'incremental'"`
// Current status: active, paused, error
Status string `json:"status" gorm:"type:varchar(32);default:'active'"`
// Conflict resolution strategy: overwrite or skip
ConflictStrategy string `json:"conflict_strategy" gorm:"type:varchar(32);default:'overwrite'"`
// Whether to sync deletions from source
SyncDeletions bool `json:"sync_deletions" gorm:"default:true"`
// Last successful sync timestamp
LastSyncAt *time.Time `json:"last_sync_at"`
// Cursor or state for incremental sync (connector-specific)
LastSyncCursor JSON `json:"last_sync_cursor" gorm:"type:jsonb"`
// Summary of last sync result
LastSyncResult JSON `json:"last_sync_result" gorm:"type:jsonb"`
// Error message if status is "error"
ErrorMessage string `json:"error_message"`
// Number of days to keep sync logs (default: 30)
SyncLogRetentionDays int `json:"sync_log_retention_days" gorm:"default:30"`
// Creation timestamp
CreatedAt time.Time `json:"created_at"`
// Last update timestamp
UpdatedAt time.Time `json:"updated_at"`
// Soft delete timestamp
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
// Total items synced (not stored in DB, calculated on query)
TotalItemsSynced int64 `json:"total_items_synced" gorm:"-"`
// Latest sync log (not stored in DB, populated on query)
LatestSyncLog *SyncLog `json:"latest_sync_log" gorm:"-"`
}
// TableName specifies the table name for DataSource
func (d *DataSource) TableName() string {
return "data_sources"
}
// BeforeCreate hook to generate UUID
func (d *DataSource) BeforeCreate(tx *gorm.DB) error {
if d.ID == "" {
d.ID = uuid.New().String()
}
return nil
}
// SyncLog records the execution of a sync task
type SyncLog struct {
// Unique identifier
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
// Reference to the data source
DataSourceID string `json:"data_source_id" gorm:"index"`
// Workspace ID
TenantID uint64 `json:"tenant_id" gorm:"index"`
// Sync status: running, success, partial, failed, canceled
Status string `json:"status" gorm:"type:varchar(32);index"`
// Sync start time
StartedAt time.Time `json:"started_at"`
// Sync completion time
FinishedAt *time.Time `json:"finished_at"`
// Total items fetched from source
ItemsTotal int `json:"items_total"`
// New items created in knowledge base
ItemsCreated int `json:"items_created"`
// Existing items updated
ItemsUpdated int `json:"items_updated"`
// Items deleted from knowledge base
ItemsDeleted int `json:"items_deleted"`
// Items skipped (no changes detected)
ItemsSkipped int `json:"items_skipped"`
// Items that failed to sync
ItemsFailed int `json:"items_failed"`
// Error details if status is "failed"
ErrorMessage string `json:"error_message"`
// Detailed sync result (JSON-encoded)
Result JSON `json:"result" gorm:"type:jsonb"`
// Creation timestamp (usually same as StartedAt)
CreatedAt time.Time `json:"created_at"`
// Last update timestamp
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for SyncLog
func (s *SyncLog) TableName() string {
return "sync_logs"
}
// BeforeCreate hook to generate UUID
func (s *SyncLog) BeforeCreate(tx *gorm.DB) error {
if s.ID == "" {
s.ID = uuid.New().String()
}
if s.StartedAt.IsZero() {
s.StartedAt = time.Now().UTC()
}
return nil
}
// DataSourceConfig represents the unencrypted configuration structure
// Each connector type will have its own specific fields.
//
// Credential management lives in the dedicated /credentials subresource
// (see internal/handler/datasource_credentials.go). Secret values are never
// included in API responses — handlers serialize via dto.NewDataSourceResponse
// which strips the Credentials map by construction.
type DataSourceConfig struct {
// Common fields applicable to most connectors
Type string `json:"type"`
// OAuth/API credentials (varies by connector)
Credentials map[string]interface{} `json:"credentials"`
// Selected resource IDs to sync (e.g., folder IDs, space IDs)
ResourceIDs []string `json:"resource_ids"`
// Connector-specific configuration
Settings map[string]interface{} `json:"settings"`
// MultimodalEnabled mirrors the target knowledge base's VLM/multimodal
// setting for the current sync run. The service populates it before each fetch
// and it is never persisted (json:"-") — the KB owns the setting. Connectors
// use it to decide whether extracting embedded images for OCR is worthwhile:
// ingesting an image into a KB without VLM is rejected, so image extraction is
// skipped when this is false.
MultimodalEnabled bool `json:"-"`
}
// HasCredentials reports whether the credentials map carries any value at
// all. Used by the Update path and by the credential subresource to decide
// whether to run live-connector validation.
func (d DataSourceConfig) HasCredentials() bool {
return len(d.Credentials) > 0
}
// HasConfiguredCredentials reports whether user-facing secret credentials are
// stored. RSS feed URLs are non-secret configuration (settings); only
// auth_headers count as credentials for that connector.
func (d DataSourceConfig) HasConfiguredCredentials(connectorType string) bool {
if len(d.Credentials) == 0 {
return false
}
switch connectorType {
case ConnectorTypeRSS:
raw, ok := d.Credentials["auth_headers"]
if !ok {
return false
}
s, ok := raw.(string)
return ok && strings.TrimSpace(s) != ""
default:
return len(d.Credentials) > 0
}
}
// StripNonSecretCredentials removes non-secret values mistakenly stored in the
// credentials map before persistence.
func (d *DataSourceConfig) StripNonSecretCredentials(connectorType string) {
if d == nil || d.Credentials == nil {
return
}
switch connectorType {
case ConnectorTypeRSS:
delete(d.Credentials, "feed_urls")
if len(d.Credentials) == 0 {
d.Credentials = nil
}
}
}
// Resource represents a syncable resource (document, folder, space) from external system
type Resource struct {
// Unique identifier in the external system
ExternalID string `json:"external_id"`
// Display name
Name string `json:"name"`
// Resource type (document, folder, space, page, etc.)
Type string `json:"type"`
// Optional description
Description string `json:"description"`
// URL to access in external system
URL string `json:"url"`
// Last modified time in external system
ModifiedAt time.Time `json:"modified_at"`
// For hierarchical resources (parent ID if applicable)
ParentID string `json:"parent_id,omitempty"`
// Whether this resource has children that can be expanded
HasChildren bool `json:"has_children,omitempty"`
// Additional metadata
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// FetchedItem represents a single document/content item fetched from external source
type FetchedItem struct {
// Unique ID in the external system
ExternalID string `json:"external_id"`
// Title of the content
Title string `json:"title"`
// Content in bytes (Markdown format preferred)
Content []byte `json:"content"`
// MIME type (text/markdown, text/html, application/pdf, etc.)
ContentType string `json:"content_type"`
// Suggested file name
FileName string `json:"file_name"`
// Original URL in external system
URL string `json:"url"`
// When last modified in external system
UpdatedAt time.Time `json:"updated_at"`
// Additional metadata to preserve
Metadata map[string]string `json:"metadata"`
// Whether the item was deleted in the source
IsDeleted bool `json:"is_deleted"`
// Source resource ID (e.g., folder ID this document belongs to)
SourceResourceID string `json:"source_resource_id"`
// ReplacesSubtree, when true, tells ingestion to reconcile this item's
// sub-items: after the parent is (re)ingested, every existing knowledge item
// whose external_id starts with SubtreeChildPrefix(ExternalID) that is NOT
// listed in SubtreeKeep is deleted as stale. Used by connectors that fan one
// source node out into a parent document plus attachment/image sub-items
// (e.g. Feishu docx).
//
// PRECONDITIONS the sweep relies on — a connector setting this MUST honour:
// 1. Child external_ids are built with SubtreeChildID, so they share the
// '#' prefix the sweep matches. A child ID without that prefix is never
// swept (silently orphaned); an unrelated item that happens to start
// with the prefix would be wrongly swept.
// 2. The parent is emitted (streaming) / listed (batch) no later than its
// children, and SubtreeKeep already names every still-present child when
// the parent is ingested. The sweep runs at parent-ingest time against
// the PRIOR sync's children, so a child emitted after its parent's sweep
// but absent from SubtreeKeep could be deleted right after being added.
ReplacesSubtree bool `json:"replaces_subtree,omitempty"`
// SubtreeKeep lists the external_ids of sub-items that still exist in the
// source this sync. Consulted only when ReplacesSubtree is true: the sweep
// preserves these children even if they could not be re-ingested this cycle
// (transient download/parse failure, or an unclassifiable filename), so a
// still-present attachment never loses its previously-synced good copy. Only
// children absent from this set — genuinely removed from the source — are
// swept.
//
// CONTRACT: an empty (or nil) SubtreeKeep with ReplacesSubtree=true means
// "keep nothing" and sweeps EVERY existing child under the prefix. That is
// correct for a node whose attachments all vanished, so a connector setting
// ReplacesSubtree MUST populate SubtreeKeep with every child still present.
// The field is consumed in-process (fetch → ingest, no serialization), so
// nil and empty are equivalent here; the omitempty tag is for API/debug
// exposure only and must not be relied on to distinguish "unset" from "empty".
SubtreeKeep []string `json:"subtree_keep,omitempty"`
}
// SubtreeChildID builds the external_id of a sub-item fanned out from a parent
// source node — e.g. a docx node's attachment or embedded image. The shape is
// "<parentExternalID>#<kind>#<token>". The '#' separator is the contract the
// subtree sweep depends on (see SubtreeChildPrefix and FetchedItem.ReplacesSubtree):
// producers MUST build child IDs with this helper so the producer and the sweep
// consumer share one source of truth. kind is a short discriminator ("file",
// "image"); token is the source system's id for the child and is assumed to be
// '#'-free (Feishu/Notion/etc. tokens are).
func SubtreeChildID(parentExternalID, kind, token string) string {
return parentExternalID + "#" + kind + "#" + token
}
// SubtreeChildPrefix is the external_id prefix that matches every child of a
// parent node built with SubtreeChildID. The sweep deletes prior children whose
// external_id starts with this prefix and are absent from SubtreeKeep. It must
// move in lockstep with SubtreeChildID: both encode the same '#' separator.
func SubtreeChildPrefix(parentExternalID string) string {
return parentExternalID + "#"
}
// SyncCursor represents the position/state for incremental sync
// Connector-specific structure allows flexibility
type SyncCursor struct {
// Timestamp of last sync
LastSyncTime time.Time `json:"last_sync_time"`
// Connector-specific cursor (e.g., pagination token, offset, etc.)
ConnectorCursor map[string]interface{} `json:"connector_cursor"`
// Hash of the last full sync to detect schema changes
LastSchemaHash string `json:"last_schema_hash"`
}
// SyncResult summarizes the outcome of a sync operation
type SyncResult struct {
// Total items processed
Total int `json:"total"`
// Items created
Created int `json:"created"`
// Items updated
Updated int `json:"updated"`
// Items deleted
Deleted int `json:"deleted"`
// Items skipped (no changes)
Skipped int `json:"skipped"`
// Items that failed
Failed int `json:"failed"`
// Deleted items whose KB deletion failed (a subset of Failed). Past the
// connector cursor, so normally only a later full sync retries them.
DeletionFailed int `json:"deletion_failed,omitempty"`
// Per-item failure samples (capped), shown in the sync-log UI.
Errors []SyncItemError `json:"errors,omitempty"`
// Updated cursor for next incremental sync
NextCursor *SyncCursor `json:"next_cursor,omitempty"`
}
// SyncItemError is one user-facing failure sample. It carries a stable i18n
// Code (+ interpolation Params) so the frontend localises it to the viewer's
// language, plus a Message fallback for clients without the key. The raw API
// status/body/log_id is never stored here — that stays in the server logs.
type SyncItemError struct {
// Title is the document title (user content, not translated).
Title string `json:"title,omitempty"`
// Code is a stable key the frontend maps to a localized string, e.g.
// "feishu_rate_limited" → datasource.syncError.feishu_rate_limited.
Code string `json:"code,omitempty"`
// Params are interpolation values for the localized string, e.g. {"code":"1663"}.
Params map[string]string `json:"params,omitempty"`
// Message is a human fallback used when the client has no i18n key for Code.
Message string `json:"message,omitempty"`
}
// Display renders the sample as a single plain string for server-side use
// (logs, fatal-error detail). The localised UI string is built on the frontend
// from Code/Params; this is only a non-localised fallback.
func (e SyncItemError) Display() string {
switch {
case e.Title != "" && e.Message != "":
return e.Title + ": " + e.Message
case e.Message != "":
return e.Message
default:
return e.Title
}
}
// UnmarshalJSON keeps old sync logs readable: historically each error was a
// plain JSON string, so a bare string decodes into Message.
func (e *SyncItemError) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
e.Message = s
return nil
}
type alias SyncItemError
var a alias
if err := json.Unmarshal(b, &a); err != nil {
return err
}
*e = SyncItemError(a)
return nil
}
// DataSourceSyncPayload represents the asynq task payload for data source sync
type DataSourceSyncPayload struct {
TracingContext
Initiator TaskInitiator `json:"initiator,omitempty"`
// Trigger distinguishes a user-requested run from a scheduler-created run.
Trigger string `json:"trigger,omitempty"`
// Data source ID to sync
DataSourceID string `json:"data_source_id"`
// Workspace ID
TenantID uint64 `json:"tenant_id"`
// Sync log ID (for tracking)
SyncLogID string `json:"sync_log_id"`
// Force full sync even if incremental mode is configured
ForceFull bool `json:"force_full"`
// Maximum number of items to fetch (0 = unlimited)
MaxItems int `json:"max_items,omitempty"`
}
// ToJSON converts a DataSourceConfig to the JSON blob stored in
// DataSource.Config.
//
// When SYSTEM_AES_KEY is configured, every string value inside
// Credentials is AES-256-GCM encrypted before serialization. Non-string
// values (numbers, bools, nested objects) pass through untouched. This is
// the only write path through which credentials reach the DB (the GORM
// JSON type itself is a byte passthrough), so encrypting here is
// sufficient to keep DataSource.Config at rest fully encrypted.
//
// Encryption operates on a shallow copy of Credentials to avoid mutating
// the caller's in-memory map (subsequent reads would otherwise see
// ciphertext).
func (d *DataSourceConfig) ToJSON() (JSON, error) {
if d == nil {
return nil, nil
}
out := *d
if key := utils.GetAESKey(); key != nil && len(out.Credentials) > 0 {
encCreds := make(map[string]interface{}, len(out.Credentials))
for k, v := range out.Credentials {
if s, ok := v.(string); ok && s != "" {
if enc, err := utils.EncryptAESGCM(s, key); err == nil {
encCreds[k] = enc
continue
}
}
encCreds[k] = v
}
out.Credentials = encCreds
}
bytes, err := json.Marshal(&out)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
// ToJSON converts a value to JSON type
func (r *SyncCursor) ToJSON() (JSON, error) {
if r == nil {
return nil, nil
}
bytes, err := json.Marshal(r)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
// ToJSON converts a value to JSON type
func (r *SyncResult) ToJSON() (JSON, error) {
if r == nil {
return nil, nil
}
bytes, err := json.Marshal(r)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
// ParseConfig deserializes DataSource.Config and decrypts any encrypted
// Credentials entries.
//
// DecryptStoredSecret transparently handles three cases per credential:
// - empty string: untouched
// - legacy plaintext (no enc:v1: prefix): returned as-is, so historical
// rows continue to work without a migration step
// - enc:v1: encrypted: decrypted with SYSTEM_AES_KEY; missing/rotated
// key surfaces as an error so we fail loudly rather than handing
// ciphertext to the upstream connector as the credential
func (d *DataSource) ParseConfig() (*DataSourceConfig, error) {
if len(d.Config) == 0 {
return nil, nil
}
var config DataSourceConfig
if err := json.Unmarshal(d.Config, &config); err != nil {
return nil, err
}
for k, v := range config.Credentials {
s, ok := v.(string)
if !ok || s == "" {
continue
}
if plain, ok := utils.DecryptStoredSecretLenient(s); ok {
config.Credentials[k] = plain
continue
}
// Same rationale as the other Scan paths: don't fail the load —
// blank the field so the row stays visible. ParseConfig callers
// then see an empty credential string; HasCredentials() returns
// false; the UI surfaces "credential not configured" and the
// user can re-enter without losing the rest of the data source.
log.Printf(
"[crypto] datasource credential %q: decrypt failed (SYSTEM_AES_KEY missing/rotated?), treating as unconfigured",
k,
)
config.Credentials[k] = ""
}
return &config, nil
}
// ParseSyncCursor parses the cursor JSON
func (d *DataSource) ParseSyncCursor() (*SyncCursor, error) {
if len(d.LastSyncCursor) == 0 {
return nil, nil
}
var cursor SyncCursor
if err := json.Unmarshal(d.LastSyncCursor, &cursor); err != nil {
return nil, err
}
return &cursor, nil
}
// ParseSyncResult parses the result JSON
func (d *DataSource) ParseSyncResult() (*SyncResult, error) {
if len(d.LastSyncResult) == 0 {
return nil, nil
}
var result SyncResult
if err := json.Unmarshal(d.LastSyncResult, &result); err != nil {
return nil, err
}
return &result, nil
}
// ParseSyncLogResult parses the result JSON from sync log
func (s *SyncLog) ParseResult() (*SyncResult, error) {
if len(s.Result) == 0 {
return nil, nil
}
var result SyncResult
if err := json.Unmarshal(s.Result, &result); err != nil {
return nil, err
}
return &result, nil
}
+168
View File
@@ -0,0 +1,168 @@
package types
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDataSourceConfig_ToJSON_EncryptsStringCredentials(t *testing.T) {
withAESKey(t, testAESKey32)
cfg := &DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"token": "ghp-real-secret",
"client_secret": "cs-real-secret",
"refresh_count": 42, // non-string, must pass through untouched
"refresh_enabled": true, // non-string, must pass through untouched
},
Settings: map[string]interface{}{"branch": "main"},
}
blob, err := cfg.ToJSON()
assert.NoError(t, err)
s := string(blob)
// Plaintext secrets must not appear in the persisted JSON.
assert.NotContains(t, s, "ghp-real-secret")
assert.NotContains(t, s, "cs-real-secret")
// String credentials are encrypted with the enc:v1: prefix.
var raw map[string]json.RawMessage
assert.NoError(t, json.Unmarshal(blob, &raw))
var creds map[string]interface{}
assert.NoError(t, json.Unmarshal(raw["credentials"], &creds))
assert.True(t, strings.HasPrefix(creds["token"].(string), "enc:v1:"))
assert.True(t, strings.HasPrefix(creds["client_secret"].(string), "enc:v1:"))
// Non-string credentials survive verbatim (some connectors persist
// integers / bools alongside the secrets — refresh_count and friends).
assert.EqualValues(t, 42, creds["refresh_count"])
assert.EqualValues(t, true, creds["refresh_enabled"])
// In-memory caller's map must not be mutated.
assert.Equal(t, "ghp-real-secret", cfg.Credentials["token"])
assert.Equal(t, "cs-real-secret", cfg.Credentials["client_secret"])
}
func TestDataSourceConfig_ToJSON_PassthroughWhenNoKey(t *testing.T) {
withAESKey(t, "")
cfg := &DataSourceConfig{
Credentials: map[string]interface{}{"token": "plain-token"},
}
blob, err := cfg.ToJSON()
assert.NoError(t, err)
assert.Contains(t, string(blob), "plain-token")
}
func TestDataSource_ParseConfig_Roundtrip(t *testing.T) {
withAESKey(t, testAESKey32)
original := &DataSourceConfig{
Type: "notion",
Credentials: map[string]interface{}{
"token": "ntn-roundtrip",
"team_id": "team-public-id",
},
ResourceIDs: []string{"page-1"},
}
blob, err := original.ToJSON()
assert.NoError(t, err)
ds := &DataSource{Config: blob}
parsed, err := ds.ParseConfig()
assert.NoError(t, err)
assert.Equal(t, "ntn-roundtrip", parsed.Credentials["token"])
assert.Equal(t, "team-public-id", parsed.Credentials["team_id"])
assert.Equal(t, []string{"page-1"}, parsed.ResourceIDs)
}
func TestDataSource_ParseConfig_LegacyPlaintext(t *testing.T) {
// Rows persisted before encryption was wired in have no enc:v1: prefix
// — DecryptStoredSecret returns them verbatim. Historical data must
// keep working without an offline migration.
withAESKey(t, testAESKey32)
legacy := JSON([]byte(`{"type":"github","credentials":{"token":"legacy-plain"}}`))
ds := &DataSource{Config: legacy}
parsed, err := ds.ParseConfig()
assert.NoError(t, err)
assert.Equal(t, "legacy-plain", parsed.Credentials["token"])
}
func TestDataSource_ParseConfig_MissingKeyDegradesGracefully(t *testing.T) {
// Encrypt with key, drop the key, then load. ParseConfig must succeed
// so the data source row is still visible — the credential blanks
// out so HasCredentials() returns false and the UI shows
// "not configured". A loud Scan failure would break the whole list.
withAESKey(t, testAESKey32)
cfg := &DataSourceConfig{
Type: "github",
Credentials: map[string]interface{}{
"token": "secret",
"refresh_count": 42, // non-string survives regardless
},
ResourceIDs: []string{"repo-1"},
}
blob, err := cfg.ToJSON()
assert.NoError(t, err)
withAESKey(t, "")
ds := &DataSource{Config: blob}
parsed, err := ds.ParseConfig()
assert.NoError(t, err)
assert.Equal(t, "", parsed.Credentials["token"], "encrypted credential must blank out, not leak ciphertext")
assert.EqualValues(t, 42, parsed.Credentials["refresh_count"], "non-string credentials unaffected")
assert.Equal(t, []string{"repo-1"}, parsed.ResourceIDs, "non-credential config survives")
assert.False(t, parsed.HasCredentials() && parsed.Credentials["token"] != "",
"HasCredentials should not report a usable credential when decrypt failed")
}
func TestDataSourceConfig_HasConfiguredCredentials_RSS(t *testing.T) {
feedOnly := DataSourceConfig{
Credentials: map[string]interface{}{
"feed_urls": "https://example.com/feed.xml",
},
}
assert.False(t, feedOnly.HasConfiguredCredentials(ConnectorTypeRSS))
assert.True(t, feedOnly.HasCredentials())
withAuth := DataSourceConfig{
Credentials: map[string]interface{}{
"feed_urls": "https://example.com/feed.xml",
"auth_headers": "Authorization: Bearer x",
},
}
assert.True(t, withAuth.HasConfiguredCredentials(ConnectorTypeRSS))
t.Run("strip feed_urls", func(t *testing.T) {
cfg := feedOnly
cfg.StripNonSecretCredentials(ConnectorTypeRSS)
assert.Nil(t, cfg.Credentials)
assert.False(t, cfg.HasCredentials())
})
}
// TestSubtreeChildID_MatchesPrefix locks the producer/consumer contract: a child
// ID built by SubtreeChildID must start with the parent's SubtreeChildPrefix
// (what the sweep queries), and the prefix must not match a sibling node whose
// external_id merely shares a leading substring.
func TestSubtreeChildID_MatchesPrefix(t *testing.T) {
parent := "node123"
prefix := SubtreeChildPrefix(parent)
assert.Equal(t, "node123#", prefix)
fileChild := SubtreeChildID(parent, "file", "tokABC")
imageChild := SubtreeChildID(parent, "image", "tokXYZ")
assert.Equal(t, "node123#file#tokABC", fileChild)
assert.Equal(t, "node123#image#tokXYZ", imageChild)
// Every child of this node is swept by the prefix.
assert.True(t, strings.HasPrefix(fileChild, prefix))
assert.True(t, strings.HasPrefix(imageChild, prefix))
// A sibling node "node1234" (shares the "node123" leading substring but is a
// different node) must NOT be caught by node123's prefix — the '#' guards it.
sibling := SubtreeChildID("node1234", "file", "tokABC")
assert.False(t, strings.HasPrefix(sibling, prefix),
"sibling node whose id shares a leading substring must not match the prefix")
}
+111
View File
@@ -0,0 +1,111 @@
package types
import (
"errors"
"time"
"gorm.io/gorm"
)
var (
ErrDigitalHumanNotFound = errors.New("数字人不存在或无权访问")
ErrDigitalHumanConflict = errors.New("数字人已更新或正在蒸馏,请刷新后重试")
ErrDigitalHumanIdentity = errors.New("请使用个人账号访问数字人")
ErrDigitalHumanPrimaryAgent = errors.New("这是数字人的默认 Agent,请到数字人页面使用解除绑定并删除")
)
// DigitalHumanScope is always derived from authentication, never request JSON.
type DigitalHumanScope struct {
TenantID uint64
OwnerID string
ReadAll bool
}
type DigitalHumanInputError struct{ Message string }
func (e *DigitalHumanInputError) Error() string { return e.Message }
type DigitalHuman struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index:idx_dh_owner,priority:1"`
OwnerID string `json:"owner_id" gorm:"type:varchar(36);not null;index:idx_dh_owner,priority:2"`
CreatorName string `json:"creator_name" gorm:"-"`
Name string `json:"name" gorm:"type:varchar(255);not null"`
Description string `json:"description" gorm:"type:text"`
Avatar string `json:"avatar" gorm:"type:varchar(64)"`
ModelID string `json:"model_id" gorm:"type:varchar(64)"`
Status string `json:"status" gorm:"type:varchar(24);not null"`
LatestVersion int `json:"latest_version"`
ActiveVersion int `json:"active_version"`
PrimaryAgentID string `json:"primary_agent_id" gorm:"type:varchar(36)"`
ProcessingJobID string `json:"processing_job_id" gorm:"type:varchar(36)"`
Revision int64 `json:"revision" gorm:"not null;default:1"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func (DigitalHuman) TableName() string { return "digital_humans" }
type DigitalHumanSource struct {
Name string `json:"name"`
Text string `json:"text"`
}
type DigitalHumanVersion struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null"`
OwnerID string `json:"owner_id" gorm:"type:varchar(36);not null"`
DigitalHumanID string `json:"digital_human_id" gorm:"type:varchar(36);not null;uniqueIndex:uq_dh_version,priority:1"`
Number int `json:"number" gorm:"not null;uniqueIndex:uq_dh_version,priority:2"`
Status string `json:"status" gorm:"type:varchar(24);not null"`
Persona map[string]string `json:"persona" gorm:"serializer:json;type:json"`
Sources []DigitalHumanSource `json:"sources" gorm:"serializer:json;type:json"`
Advice string `json:"advice" gorm:"type:text"`
ReviewNote string `json:"review_note" gorm:"type:text"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type DigitalHumanBinding struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null;uniqueIndex:uq_dh_agent,priority:1"`
OwnerID string `json:"owner_id" gorm:"type:varchar(36);not null"`
DigitalHumanID string `json:"digital_human_id" gorm:"type:varchar(36);not null;index"`
AgentID string `json:"agent_id" gorm:"type:varchar(36);not null;uniqueIndex:uq_dh_agent,priority:2"`
BasePrompt string `json:"-" gorm:"type:text"`
AppliedVersion int `json:"applied_version"`
CreatedAt time.Time `json:"created_at"`
}
type DigitalHumanJob struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null"`
OwnerID string `json:"owner_id" gorm:"type:varchar(36);not null"`
DigitalHumanID string `json:"digital_human_id" gorm:"type:varchar(36);not null;index"`
Status string `json:"status" gorm:"type:varchar(24);not null"`
Progress int `json:"progress"`
Stage string `json:"stage" gorm:"type:varchar(255)"`
Error string `json:"error" gorm:"type:text"`
Version int `json:"version"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CheckpointFingerprint string `json:"-" gorm:"type:varchar(64);not null;default:''"`
Checkpoint *DigitalHumanDistillCheckpoint `json:"-" gorm:"serializer:json;type:json"`
}
const DigitalHumanJobLease = 10 * time.Minute
type DigitalHumanDistillCheckpoint struct {
CompletedBatches int `json:"completed_batches"`
TotalBatches int `json:"total_batches"`
Persona map[string]string `json:"persona"`
}
type DigitalHumanDetail struct {
*DigitalHuman
Versions []DigitalHumanVersion `json:"versions"`
Bindings []DigitalHumanBinding `json:"bindings"`
LastJob *DigitalHumanJob `json:"last_job,omitempty"`
}
+122
View File
@@ -0,0 +1,122 @@
package types
import "strings"
// ReadRequest is the unified transport-agnostic request for document reading.
// Set FileContent for file mode, URL for URL mode.
type ReadRequest struct {
FileContent []byte
FileName string
FileType string
URL string
Title string
ParserEngine string
RequestID string
ParserEngineOverrides map[string]string
}
// ReadResult is the transport-agnostic result of document reading.
type ReadResult struct {
MarkdownContent string
ImageRefs []ImageRef
ImageDirPath string
Metadata map[string]string
Error string
IsAudio bool // true when the result contains raw audio data needing ASR transcription
AudioData []byte // raw audio bytes for ASR processing
}
// ImageRef represents an image reference extracted from the document.
type ImageRef struct {
Filename string
OriginalRef string
MimeType string
StorageKey string
ImageData []byte // inline image bytes (universal fallback for cross-machine deployments)
// IsOriginal marks references that point to the originally uploaded file
// itself (e.g. when the user uploads a standalone image). Such references
// must not be dropped by the icon/size filter — otherwise a small image
// upload would be silently discarded before multimodal processing.
IsOriginal bool
}
// ParserEngineInfo describes a registered parser engine.
type ParserEngineInfo struct {
Name string
Description string
FileTypes []string
Available bool
UnavailableReason string
}
// --- Internal types used by chunking pipeline ---
type DocParserStorageConfig struct {
Provider string
Region string
BucketName string
AccessKeyID string
SecretAccessKey string
AppID string
PathPrefix string
Endpoint string
}
type DocParserVLMConfig struct {
ModelName string
BaseURL string
APIKey string
InterfaceType string
}
type ParsedChunk struct {
Content string
// ContextHeader is an optional context string (e.g. a Markdown heading
// breadcrumb) that should be prepended at embedding time but is NOT
// part of the stored Content. Lets retrieval pipelines see section
// context without breaking End-Start == len(Content) invariants.
ContextHeader string
Seq int
Start int
End int
Images []ParsedImage
ChunkID string // populated by processChunks with the actual DB UUID
// ParentIndex is set when using parent-child chunking strategy.
// -1 (or unset/0 for flat chunks) means this is a top-level chunk.
// >= 0 means this is a child chunk referencing the parent at this index
// in the ParentChunks slice of ProcessChunksOptions.
ParentIndex int
}
// EmbeddingContent returns the text that should be sent to the embedding
// model: ContextHeader (if any) prepended to Content. Mirrors
// chunker.Chunk.EmbeddingContent so the choice is consistent across the
// chunker output and the indexing pipeline. Surrounding whitespace on
// Content is trimmed so leading/trailing newlines from boundary slicing
// don't dilute the embedded vector.
func (c ParsedChunk) EmbeddingContent() string {
body := strings.TrimSpace(c.Content)
if c.ContextHeader == "" {
return body
}
return c.ContextHeader + "\n\n" + body
}
// ParsedParentChunk represents a parent chunk in the parent-child strategy.
// Parent chunks are stored in DB for context retrieval but NOT vector-indexed.
type ParsedParentChunk struct {
Content string
Seq int
Start int
End int
}
type ParsedImage struct {
URL string
Caption string
OCRText string
OriginalURL string
Start int
End int
}
+148
View File
@@ -0,0 +1,148 @@
package types
import (
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// EmbedChannel publishes an agent chat surface for external websites.
type EmbedChannel struct {
ID string `json:"id" gorm:"type:varchar(36);primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"not null;index:idx_embed_channels_tenant"`
AgentID string `json:"agent_id" gorm:"type:varchar(36);not null;index:idx_embed_channels_agent;default:'builtin-quick-answer'"`
Name string `json:"name" gorm:"type:varchar(255);not null;default:''"`
Enabled bool `json:"enabled" gorm:"not null;default:true"`
PublishToken string `json:"-" gorm:"type:varchar(64);not null;default:''"`
AllowedOrigins JSON `json:"allowed_origins" gorm:"type:jsonb;not null;default:'[]'"`
WelcomeMessage string `json:"welcome_message" gorm:"type:text;not null;default:''"`
RateLimitPerMinute int `json:"rate_limit_per_minute" gorm:"not null;default:30"`
RateLimitPerDay int `json:"rate_limit_per_day" gorm:"not null;default:10000"`
PrimaryColor string `json:"primary_color" gorm:"type:varchar(32);not null;default:''"`
PageTitle string `json:"page_title" gorm:"type:varchar(255);not null;default:''"`
HeaderTitleMode string `json:"header_title_mode" gorm:"type:varchar(32);not null;default:'channel'"`
ShowSuggestedQuestions bool `json:"show_suggested_questions" gorm:"not null;default:true"`
WidgetPosition string `json:"widget_position" gorm:"type:varchar(32);not null;default:'bottom-right'"`
AllowWebSearch bool `json:"allow_web_search" gorm:"not null;default:false"`
AllowFileUpload bool `json:"allow_file_upload" gorm:"not null;default:false"`
DefaultLocale string `json:"default_locale" gorm:"type:varchar(16);not null;default:''"`
WebhookURL string `json:"webhook_url" gorm:"type:varchar(512);not null;default:''"`
WebhookSecret string `json:"-" gorm:"type:varchar(128);not null;default:''"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
}
func (EmbedChannel) TableName() string { return "embed_channels" }
func (ch *EmbedChannel) BeforeCreate(tx *gorm.DB) error {
if ch.ID == "" {
ch.ID = uuid.New().String()
}
if ch.AgentID == "" {
ch.AgentID = BuiltinQuickAnswerID
}
if ch.RateLimitPerMinute <= 0 {
ch.RateLimitPerMinute = 30
}
if ch.RateLimitPerDay <= 0 {
ch.RateLimitPerDay = DefaultEmbedRateLimitPerDay
}
if ch.WidgetPosition == "" {
ch.WidgetPosition = DefaultEmbedWidgetPosition
}
if ch.HeaderTitleMode == "" {
ch.HeaderTitleMode = DefaultEmbedHeaderTitleMode
}
return nil
}
// DefaultEmbedRateLimitPerDay caps total embed requests per channel per day
// (across all client IPs), bounding cost/abuse when the publicly visible
// publish token is copied and replayed from rotating IPs.
const DefaultEmbedRateLimitPerDay = 10000
const DefaultEmbedWidgetPosition = "bottom-right"
const DefaultEmbedHeaderTitleMode = "channel"
const EmbedHeaderTitleModeSession = "session"
// NormalizeEmbedWidgetPosition returns a supported widget corner or the default.
func NormalizeEmbedWidgetPosition(position string) string {
switch strings.TrimSpace(position) {
case "bottom-left", "top-right", "top-left", "bottom-right":
return strings.TrimSpace(position)
default:
return DefaultEmbedWidgetPosition
}
}
// NormalizeEmbedHeaderTitleMode returns a supported header title source.
func NormalizeEmbedHeaderTitleMode(mode string) string {
switch strings.TrimSpace(mode) {
case EmbedHeaderTitleModeSession:
return EmbedHeaderTitleModeSession
default:
return DefaultEmbedHeaderTitleMode
}
}
// AllowedOriginsList decodes the JSON array of origin patterns.
func (ch *EmbedChannel) AllowedOriginsList() []string {
if len(ch.AllowedOrigins) == 0 {
return nil
}
var origins []string
if err := json.Unmarshal(ch.AllowedOrigins, &origins); err != nil {
return nil
}
return origins
}
// EmbedChannelPublicConfig is returned to anonymous embed clients (no secrets).
type EmbedChannelPublicConfig struct {
ChannelID string `json:"channel_id"`
Name string `json:"name"`
DisplayTitle string `json:"display_title"`
KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"`
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name,omitempty"`
AgentAvatar string `json:"agent_avatar,omitempty"`
WelcomeMessage string `json:"welcome_message"`
PrimaryColor string `json:"primary_color,omitempty"`
PageTitle string `json:"page_title,omitempty"`
HeaderTitleMode string `json:"header_title_mode,omitempty"`
ShowSuggestedQuestions bool `json:"show_suggested_questions"`
AllowedOrigins []string `json:"allowed_origins,omitempty"`
WidgetPosition string `json:"widget_position,omitempty"`
AllowWebSearch bool `json:"allow_web_search"`
AllowFileUpload bool `json:"allow_file_upload"`
// AgentWebSearchEnabled reflects whether the bound agent has web search configured.
AgentWebSearchEnabled bool `json:"agent_web_search_enabled"`
// AgentImageUploadEnabled reflects whether the bound agent supports image upload.
AgentImageUploadEnabled bool `json:"agent_image_upload_enabled"`
DefaultLocale string `json:"default_locale,omitempty"`
}
// Supported embed UI locales.
var supportedEmbedLocales = map[string]struct{}{
"zh-CN": {},
"en-US": {},
"ko-KR": {},
"ru-RU": {},
}
// NormalizeEmbedDefaultLocale returns a supported locale tag or empty string
// (meaning follow browser / host widget locale).
func NormalizeEmbedDefaultLocale(locale string) string {
locale = strings.TrimSpace(locale)
if _, ok := supportedEmbedLocales[locale]; ok {
return locale
}
return ""
}
// EmbedSessionMarkerPrefix tags sessions created through an embed channel.
const EmbedSessionMarkerPrefix = "embed_channel:"
+40
View File
@@ -0,0 +1,40 @@
package types
import "testing"
func TestNormalizeEmbedWidgetPosition(t *testing.T) {
cases := map[string]string{
"bottom-right": DefaultEmbedWidgetPosition,
"bottom-left": "bottom-left",
"top-right": "top-right",
"top-left": "top-left",
"": DefaultEmbedWidgetPosition,
"center": DefaultEmbedWidgetPosition,
" bottom-left ": "bottom-left",
"BOTTOM-RIGHT": DefaultEmbedWidgetPosition,
"bottom-right-extra": DefaultEmbedWidgetPosition,
"middle": DefaultEmbedWidgetPosition,
"top-center": DefaultEmbedWidgetPosition,
" ": DefaultEmbedWidgetPosition,
}
for in, want := range cases {
if got := NormalizeEmbedWidgetPosition(in); got != want {
t.Fatalf("NormalizeEmbedWidgetPosition(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeEmbedHeaderTitleMode(t *testing.T) {
cases := map[string]string{
"": DefaultEmbedHeaderTitleMode,
"channel": DefaultEmbedHeaderTitleMode,
"session": EmbedHeaderTitleModeSession,
"SESSION": DefaultEmbedHeaderTitleMode,
"other": DefaultEmbedHeaderTitleMode,
}
for in, want := range cases {
if got := NormalizeEmbedHeaderTitleMode(in); got != want {
t.Fatalf("NormalizeEmbedHeaderTitleMode(%q) = %q, want %q", in, got, want)
}
}
}
+41
View File
@@ -0,0 +1,41 @@
package types
// SourceType represents the type of content source
type SourceType int
const (
ChunkSourceType SourceType = iota // Source is a text chunk
PassageSourceType // Source is a passage
SummarySourceType // Source is a summary
)
// MatchType represents the type of matching algorithm
type MatchType int
const (
MatchTypeEmbedding MatchType = iota
MatchTypeKeywords
MatchTypeNearByChunk
MatchTypeHistory
MatchTypeParentChunk // 父Chunk匹配类型
MatchTypeRelationChunk // 关系Chunk匹配类型
MatchTypeGraph
MatchTypeWebSearch // 网络搜索匹配类型
MatchTypeDirectLoad // Deprecated: reserved to preserve serialized enum values
MatchTypeDataAnalysis // 数据分析匹配类型
)
// IndexInfo contains information about indexed content
type IndexInfo struct {
ID string // Unique identifier
Content string // Content text
SourceID string // ID of the source document
SourceType SourceType // Type of the source
ChunkID string // ID of the text chunk
KnowledgeID string // ID of the knowledge
KnowledgeBaseID string // ID of the knowledge base
KnowledgeType string // Type of the knowledge (e.g., "faq", "manual")
TagID string // Tag ID for categorization (used for FAQ priority filtering)
IsEnabled bool // Whether the chunk is enabled for retrieval
IsRecommended bool // Whether the chunk is recommended
}
+46
View File
@@ -0,0 +1,46 @@
package types
import "fmt"
// StorageQuotaExceededError represents the storage quota exceeded error
type StorageQuotaExceededError struct {
Message string
}
// Error implements the error interface
func (e *StorageQuotaExceededError) Error() string {
return e.Message
}
// NewStorageQuotaExceededError creates a storage quota exceeded error
func NewStorageQuotaExceededError() *StorageQuotaExceededError {
return &StorageQuotaExceededError{
Message: "Storage quota exceeded",
}
}
// DuplicateKnowledgeError duplicate knowledge error, contains the existing knowledge object
type DuplicateKnowledgeError struct {
Message string
Knowledge *Knowledge
}
func (e *DuplicateKnowledgeError) Error() string {
return e.Message
}
// NewDuplicateFileError creates a duplicate file error
func NewDuplicateFileError(knowledge *Knowledge) *DuplicateKnowledgeError {
return &DuplicateKnowledgeError{
Message: fmt.Sprintf("File already exists: %s", knowledge.FileName),
Knowledge: knowledge,
}
}
// NewDuplicateURLError creates a duplicate URL error
func NewDuplicateURLError(knowledge *Knowledge) *DuplicateKnowledgeError {
return &DuplicateKnowledgeError{
Message: fmt.Sprintf("URL already exists: %s", knowledge.Source),
Knowledge: knowledge,
}
}
+116
View File
@@ -0,0 +1,116 @@
package types
import (
"encoding/json"
"os"
"path/filepath"
"time"
"github.com/yanyiwu/gojieba"
)
// Jieba is a global instance of Chinese text segmentation tool
var Jieba *gojieba.Jieba = newJieba()
func newJieba() *gojieba.Jieba {
dictDir := os.Getenv("JIEBA_DICT_DIR")
if dictDir == "" {
return gojieba.NewJieba()
}
return gojieba.NewJieba(
filepath.Join(dictDir, "jieba.dict.utf8"),
filepath.Join(dictDir, "hmm_model.utf8"),
filepath.Join(dictDir, "user.dict.utf8"),
filepath.Join(dictDir, "idf.utf8"),
filepath.Join(dictDir, "stop_words.utf8"),
)
}
// EvaluationStatue represents the status of an evaluation task
type EvaluationStatue int
const (
EvaluationStatuePending EvaluationStatue = iota // Task is waiting to start
EvaluationStatueRunning // Task is in progress
EvaluationStatueSuccess // Task completed successfully
EvaluationStatueFailed // Task failed
)
// EvaluationTask contains information about an evaluation task
type EvaluationTask struct {
ID string `json:"id"` // Unique task ID
TenantID uint64 `json:"tenant_id"` // Tenant/Organization ID
DatasetID string `json:"dataset_id"` // Dataset ID for evaluation
StartTime time.Time `json:"start_time"` // Task start time
Status EvaluationStatue `json:"status"` // Current task status
ErrMsg string `json:"err_msg,omitempty"` // Error message if failed
Total int `json:"total,omitempty"` // Total items to evaluate
Finished int `json:"finished,omitempty"` // Completed items count
}
// EvaluationDetail contains detailed evaluation information
type EvaluationDetail struct {
Task *EvaluationTask `json:"task"` // Evaluation task info
Params *ChatManage `json:"params"` // Evaluation parameters
Metric *MetricResult `json:"metric,omitempty"` // Evaluation metrics
}
// String returns JSON representation of EvaluationTask
func (e *EvaluationTask) String() string {
b, _ := json.Marshal(e)
return string(b)
}
// MetricInput contains input data for metric calculation
type MetricInput struct {
RetrievalGT [][]int // Ground truth for retrieval
RetrievalIDs []int // Retrieved IDs
GeneratedTexts string // Generated text for evaluation
GeneratedGT string // Ground truth text for comparison
}
// MetricResult contains evaluation metrics
type MetricResult struct {
RetrievalMetrics RetrievalMetrics `json:"retrieval_metrics"` // Retrieval performance metrics
GenerationMetrics GenerationMetrics `json:"generation_metrics"` // Text generation quality metrics
}
// RetrievalMetrics contains metrics for retrieval evaluation
type RetrievalMetrics struct {
Precision float64 `json:"precision"` // Precision score
Recall float64 `json:"recall"` // Recall score
NDCG3 float64 `json:"ndcg3"` // Normalized Discounted Cumulative Gain at 3
NDCG10 float64 `json:"ndcg10"` // Normalized Discounted Cumulative Gain at 10
MRR float64 `json:"mrr"` // Mean Reciprocal Rank
MAP float64 `json:"map"` // Mean Average Precision
}
// GenerationMetrics contains metrics for text generation evaluation
type GenerationMetrics struct {
BLEU1 float64 `json:"bleu1"` // BLEU-1 score
BLEU2 float64 `json:"bleu2"` // BLEU-2 score
BLEU4 float64 `json:"bleu4"` // BLEU-4 score
ROUGE1 float64 `json:"rouge1"` // ROUGE-1 score
ROUGE2 float64 `json:"rouge2"` // ROUGE-2 score
ROUGEL float64 `json:"rougel"` // ROUGE-L score
}
// EvalState represents different stages of evaluation process
type EvalState int
const (
StateBegin EvalState = iota // Evaluation started
StateAfterQaPairs // After loading QA pairs
StateAfterDataset // After processing dataset
StateAfterEmbedding // After generating embeddings
StateAfterVectorSearch // After vector search
StateAfterRerank // After reranking
StateAfterComplete // After completion
StateEnd // Evaluation ended
)
+30
View File
@@ -0,0 +1,30 @@
package types
import (
"context"
)
// EventHandler is a function that handles events
type EventHandler func(ctx context.Context, evt Event) error
// Event represents an event in the system
// This is a simplified version to avoid import cycle with event package
type Event struct {
ID string // Event ID
Type EventType // Event type (uses EventType from chat_manage.go)
SessionID string // Session ID
Data interface{} // Event data
Metadata map[string]interface{} // Event metadata
RequestID string // Request ID
}
// EventBusInterface defines the interface for event bus operations
// This interface allows types package to use EventBus without importing the concrete type
// and avoids circular dependencies
type EventBusInterface interface {
// On registers an event handler for a specific event type
On(eventType EventType, handler EventHandler)
// Emit publishes an event to all registered handlers
Emit(ctx context.Context, evt Event) error
}
+54
View File
@@ -0,0 +1,54 @@
package types
// ChunkContext represents chunk content with surrounding context
type ChunkContext struct {
ChunkID string `json:"chunk_id"`
Content string `json:"content"`
PrevContent string `json:"prev_content,omitempty"` // Previous chunk content for context
NextContent string `json:"next_content,omitempty"` // Next chunk content for context
}
// PromptTemplateStructured represents the prompt template structured
type PromptTemplateStructured struct {
Description string `json:"description"`
Tags []string `json:"tags"`
Examples []GraphData `json:"examples"`
}
type GraphNode struct {
Name string `json:"name,omitempty"`
Chunks []string `json:"chunks,omitempty"`
Attributes []string `json:"attributes,omitempty"`
}
// GraphRelation represents the relation of the graph
type GraphRelation struct {
Node1 string `json:"node1,omitempty"`
Node2 string `json:"node2,omitempty"`
Type string `json:"type,omitempty"`
Evidence string `json:"evidence,omitempty"`
}
type GraphData struct {
Text string `json:"text,omitempty"`
Node []*GraphNode `json:"node,omitempty"`
Relation []*GraphRelation `json:"relation,omitempty"`
}
// NameSpace represents the name space of the knowledge base and knowledge
type NameSpace struct {
KnowledgeBase string `json:"knowledge_base"`
Knowledge string `json:"knowledge"`
}
// Labels returns the labels of the name space
func (n NameSpace) Labels() []string {
res := make([]string, 0)
if n.KnowledgeBase != "" {
res = append(res, n.KnowledgeBase)
}
if n.Knowledge != "" {
res = append(res, n.Knowledge)
}
return res
}
+794
View File
@@ -0,0 +1,794 @@
package types
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"regexp"
"sort"
"strings"
"time"
"unicode"
"github.com/longbridgeapp/opencc"
)
// FAQChunkMetadata 定义 FAQ 条目在 Chunk.Metadata 中的结构
type FAQChunkMetadata struct {
StandardQuestion string `json:"standard_question"`
SimilarQuestions []string `json:"similar_questions,omitempty"`
NegativeQuestions []string `json:"negative_questions,omitempty"`
Answers []string `json:"answers,omitempty"`
AnswerStrategy AnswerStrategy `json:"answer_strategy,omitempty"`
Version int `json:"version,omitempty"`
Source string `json:"source,omitempty"`
}
// GeneratedQuestion 表示AI生成的单个问题
type GeneratedQuestion struct {
ID string `json:"id"` // 唯一标识,用于构造 source_id
Question string `json:"question"` // 问题内容
ContentRevision *int `json:"content_revision,omitempty"` // 该问题对应的 Chunk 内容版本
}
const maxGeneratedQuestionSourceIDLength = 64
// GeneratedQuestionSourceID builds the retrieval source identifier for a
// generated question. PostgreSQL stores source_id as varchar(64), while a
// chunk UUID plus a question UUID would be 73 bytes. Preserve the historical
// representation for short IDs and hash only oversized question IDs so
// existing index rows remain addressable by delete/reindex operations.
func GeneratedQuestionSourceID(chunkID, questionID string) string {
candidate := chunkID + "-" + questionID
if len(candidate) <= maxGeneratedQuestionSourceIDLength {
return candidate
}
digest := sha256.Sum256([]byte(questionID))
// UUID chunk IDs use 36 bytes; "-q" plus 24 hex characters keeps the
// complete identifier at 62 bytes while retaining ample collision space.
return chunkID + "-q" + hex.EncodeToString(digest[:12])
}
// DocumentChunkMetadata 定义文档 Chunk 的元数据结构
// 用于存储AI生成的问题等增强信息
type DocumentChunkMetadata struct {
// GeneratedQuestions 存储AI为该Chunk生成的相关问题
// 这些问题会被独立索引以提高召回率
GeneratedQuestions []GeneratedQuestion `json:"generated_questions,omitempty"`
// GeneratedQuestionsRevision ties the questions to Chunk.ContentRevision.
GeneratedQuestionsRevision int `json:"generated_questions_revision,omitempty"`
}
// IsQuestionCurrent reports whether a generated question was authored for the
// current chunk body. This is advisory metadata for the UI: questions remain
// valid retrieval aliases across chunk edits. Legacy rows fall back to the
// metadata-level revision.
func (m *DocumentChunkMetadata) IsQuestionCurrent(question GeneratedQuestion, chunkRevision int) bool {
if question.ContentRevision != nil {
return *question.ContentRevision == chunkRevision
}
return m != nil && m.GeneratedQuestionsRevision == chunkRevision
}
// GetQuestionStrings 返回问题内容字符串列表(兼容旧代码)
func (m *DocumentChunkMetadata) GetQuestionStrings() []string {
if m == nil || len(m.GeneratedQuestions) == 0 {
return nil
}
result := make([]string, len(m.GeneratedQuestions))
for i, q := range m.GeneratedQuestions {
result[i] = q.Question
}
return result
}
// DocumentMetadata 解析 Chunk 中的文档元数据
func (c *Chunk) DocumentMetadata() (*DocumentChunkMetadata, error) {
if c == nil || len(c.Metadata) == 0 {
return nil, nil
}
var meta DocumentChunkMetadata
if err := json.Unmarshal(c.Metadata, &meta); err != nil {
return nil, err
}
return &meta, nil
}
// SetDocumentMetadata 设置 Chunk 的文档元数据
func (c *Chunk) SetDocumentMetadata(meta *DocumentChunkMetadata) error {
if c == nil {
return nil
}
if meta == nil {
c.Metadata = nil
return nil
}
bytes, err := json.Marshal(meta)
if err != nil {
return err
}
c.Metadata = JSON(bytes)
return nil
}
// Sanitize 对元数据进行基础清理(去除首尾空白、去重),保留原始内容
// 用于 DB 存储,不做语义归一化
func (m *FAQChunkMetadata) Sanitize() {
if m == nil {
return
}
m.StandardQuestion = strings.TrimSpace(m.StandardQuestion)
m.SimilarQuestions = SanitizeStrings(m.SimilarQuestions)
m.NegativeQuestions = SanitizeStrings(m.NegativeQuestions)
m.Answers = SanitizeStrings(m.Answers)
if m.Version <= 0 {
m.Version = 1
}
}
// Normalize 返回归一化后的副本,用于 Hash 计算和向量索引
// 原始数据不变,返回新的归一化副本
func (m *FAQChunkMetadata) Normalize() *FAQChunkMetadata {
if m == nil {
return nil
}
return &FAQChunkMetadata{
StandardQuestion: NormalizeQuestion(m.StandardQuestion),
SimilarQuestions: normalizeQuestionStrings(m.SimilarQuestions),
NegativeQuestions: normalizeQuestionStrings(m.NegativeQuestions),
Answers: SanitizeStrings(m.Answers), // 答案只做基础清理
AnswerStrategy: m.AnswerStrategy,
Version: m.Version,
Source: m.Source,
}
}
// SanitizeStrings 对字符串列表进行基础清理(TrimSpace + 去重)
func SanitizeStrings(values []string) []string {
if len(values) == 0 {
return nil
}
dedup := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
if _, exists := seen[trimmed]; exists {
continue
}
seen[trimmed] = struct{}{}
dedup = append(dedup, trimmed)
}
if len(dedup) == 0 {
return nil
}
return dedup
}
// FAQMetadata 解析 Chunk 中的 FAQ 元数据
// 返回原始数据(仅做基础清理)
func (c *Chunk) FAQMetadata() (*FAQChunkMetadata, error) {
if c == nil || len(c.Metadata) == 0 {
return nil, nil
}
var meta FAQChunkMetadata
if err := json.Unmarshal(c.Metadata, &meta); err != nil {
return nil, err
}
meta.Sanitize() // 只做基础清理,保留原始内容
return &meta, nil
}
// SetFAQMetadata 设置 Chunk 的 FAQ 元数据
// DB 存储原始数据,ContentHash 基于归一化数据计算
func (c *Chunk) SetFAQMetadata(meta *FAQChunkMetadata) error {
if c == nil {
return nil
}
if meta == nil {
c.Metadata = nil
c.ContentHash = ""
return nil
}
// 基础清理后存储到 DB(保留原始内容)
meta.Sanitize()
bytes, err := json.Marshal(meta)
if err != nil {
return err
}
c.Metadata = JSON(bytes)
// ContentHash 基于归一化后的数据计算,用于去重匹配
normalized := meta.Normalize()
c.ContentHash = CalculateFAQContentHash(normalized)
return nil
}
// CalculateFAQContentHash 计算 FAQ 内容的 hash 值
// hash 基于:标准问 + 相似问(排序后)+ 反例(排序后)+ 答案(排序后)
// 用于快速匹配和去重
func CalculateFAQContentHash(meta *FAQChunkMetadata) string {
if meta == nil {
return ""
}
// Normalize() returns a new copy; the old code discarded the return value.
normalized := meta.Normalize()
if normalized == nil {
return ""
}
// 对数组进行排序(确保相同内容产生相同 hash)
similarQuestions := make([]string, len(normalized.SimilarQuestions))
copy(similarQuestions, normalized.SimilarQuestions)
sort.Strings(similarQuestions)
negativeQuestions := make([]string, len(normalized.NegativeQuestions))
copy(negativeQuestions, normalized.NegativeQuestions)
sort.Strings(negativeQuestions)
answers := make([]string, len(normalized.Answers))
copy(answers, normalized.Answers)
sort.Strings(answers)
// 构建用于 hash 的字符串:标准问 + 相似问 + 反例 + 答案
var builder strings.Builder
builder.WriteString(normalized.StandardQuestion)
builder.WriteString("|")
builder.WriteString(strings.Join(similarQuestions, ","))
builder.WriteString("|")
builder.WriteString(strings.Join(negativeQuestions, ","))
builder.WriteString("|")
builder.WriteString(strings.Join(answers, ","))
// 计算 SHA256 hash
hash := sha256.Sum256([]byte(builder.String()))
return hex.EncodeToString(hash[:])
}
// AnswerStrategy 定义答案返回策略
type AnswerStrategy string
const (
// AnswerStrategyAll 返回所有答案
AnswerStrategyAll AnswerStrategy = "all"
// AnswerStrategyRandom 随机返回一个答案
AnswerStrategyRandom AnswerStrategy = "random"
)
// FAQEntry 表示返回给前端的 FAQ 条目
type FAQEntry struct {
ID int64 `json:"id"`
ChunkID string `json:"chunk_id"`
KnowledgeID string `json:"knowledge_id"`
KnowledgeBaseID string `json:"knowledge_base_id"`
TagID int64 `json:"tag_id"`
TagName string `json:"tag_name"`
IsEnabled bool `json:"is_enabled"`
IsRecommended bool `json:"is_recommended"`
StandardQuestion string `json:"standard_question"`
SimilarQuestions []string `json:"similar_questions"`
NegativeQuestions []string `json:"negative_questions"`
Answers []string `json:"answers"`
AnswerStrategy AnswerStrategy `json:"answer_strategy"`
IndexMode FAQIndexMode `json:"index_mode"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
Score float64 `json:"score,omitempty"`
MatchType MatchType `json:"match_type,omitempty"`
ChunkType ChunkType `json:"chunk_type"`
// MatchedQuestion is the actual question text that was matched in FAQ search
// Could be the standard question or one of the similar questions
MatchedQuestion string `json:"matched_question,omitempty"`
}
// FAQExportEntry 表示 JSON 导出的 FAQ 条目,与 FAQEntryPayload 的导入格式兼容,
// 便于"导出 → 编辑 → 重新导入"循环。新增字段时务必保留 omitempty,避免破坏
// 历史导出文件的兼容性。
type FAQExportEntry struct {
ID int64 `json:"id"`
TagName string `json:"tag_name,omitempty"`
StandardQuestion string `json:"standard_question"`
SimilarQuestions []string `json:"similar_questions,omitempty"`
NegativeQuestions []string `json:"negative_questions,omitempty"`
Answers []string `json:"answers,omitempty"`
AnswerStrategy AnswerStrategy `json:"answer_strategy,omitempty"`
IsEnabled bool `json:"is_enabled"`
IsRecommended bool `json:"is_recommended"`
}
// FAQEntryPayload 用于创建/更新 FAQ 条目的 payload
type FAQEntryPayload struct {
// ID 可选,用于数据迁移时指定 seq_id(必须小于自增起始值 100000000)
ID *int64 `json:"id,omitempty"`
StandardQuestion string `json:"standard_question" binding:"required"`
SimilarQuestions []string `json:"similar_questions"`
NegativeQuestions []string `json:"negative_questions"`
Answers []string `json:"answers"`
AnswerStrategy *AnswerStrategy `json:"answer_strategy,omitempty"`
TagID int64 `json:"tag_id"`
TagName string `json:"tag_name"`
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
}
const (
FAQBatchModeAppend = "append"
FAQBatchModeReplace = "replace"
)
// FAQBatchUpsertPayload 批量导入 FAQ 条目
type FAQBatchUpsertPayload struct {
Entries []FAQEntryPayload `json:"entries" binding:"required"`
Mode string `json:"mode" binding:"oneof=append replace"`
KnowledgeID string `json:"knowledge_id"`
TaskID string `json:"task_id"` // 可选,如果不传则自动生成UUID
DryRun bool `json:"dry_run"` // 仅验证,不实际导入
}
// FAQFailedEntry 表示导入/验证失败的条目
type FAQFailedEntry struct {
Index int `json:"index"` // 条目在批次中的索引(从0开始)
Reason string `json:"reason"` // 失败原因
FailureType string `json:"failure_type,omitempty"` // 失败类型:pre_validation / post_validation
IsPartialFailure bool `json:"is_partial_failure,omitempty"` // 是否为部分失败(相似问/反例被移除,但整条仍可导入)
TagName string `json:"tag_name,omitempty"` // 分类
StandardQuestion string `json:"standard_question"` // 标准问题
SimilarQuestions []string `json:"similar_questions,omitempty"` // 相似问题
NegativeQuestions []string `json:"negative_questions,omitempty"` // 反例问题
Answers []string `json:"answers,omitempty"` // 答案
AnswerAll bool `json:"answer_all,omitempty"` // 是否全部回复
IsDisabled bool `json:"is_disabled,omitempty"` // 是否停用
// 部分失败详情(当 IsPartialFailure 为 true 时)
RemovedSimilarQuestions []string `json:"removed_similar_questions,omitempty"` // 被移除的相似问及原因
RemovedNegativeQuestions []string `json:"removed_negative_questions,omitempty"` // 被移除的反例及原因
}
// FAQMergeDetail 表示一条 FAQ 在 append 模式下与已有 chunk 合并的结果摘要。
type FAQMergeDetail struct {
Index int `json:"index"` // 行号(在批次中的索引)
StandardQuestion string `json:"standard_question"` // 标准问
AnswerChanged bool `json:"answer_changed"` // 答案是否变更
NewSimilarCount int `json:"new_similar_count"` // 新增相似问数量
NewNegativeCount int `json:"new_negative_count"` // 新增反例数量
}
// FAQSuccessEntry 表示导入成功的条目简单信息
type FAQSuccessEntry struct {
Index int `json:"index"` // 条目在批次中的索引(从0开始)
SeqID int64 `json:"seq_id"` // 导入后的条目序列ID
TagID int64 `json:"tag_id,omitempty"` // 分类ID(seq_id)
TagName string `json:"tag_name,omitempty"` // 分类名称
StandardQuestion string `json:"standard_question"` // 标准问题
}
// FAQDryRunResult 表示 dry_run 模式的验证结果
type FAQDryRunResult struct {
TaskID string `json:"task_id,omitempty"` // 异步任务ID(异步模式时返回)
Total int `json:"total"` // 总条目数
SuccessCount int `json:"success_count"` // 验证通过的条目数
FailedCount int `json:"failed_count"` // 验证失败的条目数
FailedEntries []FAQFailedEntry `json:"failed_entries"` // 失败条目详情
}
// FAQSearchRequest FAQ检索请求参数
type FAQSearchRequest struct {
QueryText string `json:"query_text" binding:"required"`
VectorThreshold float64 `json:"vector_threshold"`
MatchCount int `json:"match_count"`
FirstPriorityTagIDs []int64 `json:"first_priority_tag_ids"` // 第一优先级标签ID列表,限定命中范围,优先级最高
SecondPriorityTagIDs []int64 `json:"second_priority_tag_ids"` // 第二优先级标签ID列表,限定命中范围,优先级低于第一优先级
OnlyRecommended bool `json:"only_recommended"` // 是否仅返回推荐的条目
}
// UntaggedTagName is the default tag name for entries without a tag
const UntaggedTagName = "未分类"
// FAQEntryFieldsUpdate 单个FAQ条目的字段更新
type FAQEntryFieldsUpdate struct {
IsEnabled *bool `json:"is_enabled,omitempty"`
IsRecommended *bool `json:"is_recommended,omitempty"`
TagID *int64 `json:"tag_id,omitempty"`
// 后续可扩展更多字段
}
// FAQEntryFieldsBatchUpdate 批量更新FAQ条目字段的请求
// 支持两种模式:
// 1. 按条目ID更新:使用 ByID 字段
// 2. 按Tag更新:使用 ByTag 字段,将该Tag下所有条目应用相同的更新
type FAQEntryFieldsBatchUpdate struct {
// ByID 按条目ID更新,key为条目ID (seq_id)
ByID map[int64]FAQEntryFieldsUpdate `json:"by_id,omitempty"`
// ByTag 按Tag批量更新,key为TagID (seq_id)
ByTag map[int64]FAQEntryFieldsUpdate `json:"by_tag,omitempty"`
// ExcludeIDs 在ByTag操作中需要排除的ID列表 (seq_id)
ExcludeIDs []int64 `json:"exclude_ids,omitempty"`
}
// FAQImportTaskStatus 导入任务状态
type FAQImportTaskStatus string
const (
// FAQImportStatusPending represents the pending status of the FAQ import task
FAQImportStatusPending FAQImportTaskStatus = "pending"
// FAQImportStatusProcessing represents the processing status of the FAQ import task
FAQImportStatusProcessing FAQImportTaskStatus = "processing"
// FAQImportStatusCompleted represents the completed status of the FAQ import task
FAQImportStatusCompleted FAQImportTaskStatus = "completed"
// FAQImportStatusFailed represents the failed status of the FAQ import task
FAQImportStatusFailed FAQImportTaskStatus = "failed"
)
// FAQImportProgress represents the progress of an FAQ import task stored in Redis
// When Status is "completed", the result fields (SkippedCount, ImportMode, ImportedAt, DisplayStatus, ProcessingTime) are populated.
type FAQImportProgress struct {
TaskID string `json:"task_id"` // UUID for the import task
KBID string `json:"kb_id"` // Knowledge Base ID
KnowledgeID string `json:"knowledge_id"` // FAQ Knowledge ID
Status FAQImportTaskStatus `json:"status"` // Task status
Progress int `json:"progress"` // 0-100 percentage
Total int `json:"total"` // Total entries to import
Processed int `json:"processed"` // Entries processed so far
SuccessCount int `json:"success_count"` // 完全成功的条目数(不包含部分成功/部分失败)
FailedCount int `json:"failed_count"` // 失败的条目数
PartialFailedCount int `json:"partial_failed_count,omitempty"` // 部分失败的条目数(相似问/反例被移除)
SkippedCount int `json:"skipped_count,omitempty"` // 跳过的条目数(如重复等)
FailedEntries []FAQFailedEntry `json:"failed_entries,omitempty"` // 失败条目详情(少量时直接返回)
FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL(大量时返回URL)
SuccessEntries []FAQSuccessEntry `json:"success_entries,omitempty"` // 成功条目简单信息(少量时直接返回)
ValidEntryIndices []int `json:"valid_entry_indices,omitempty"` // 验证通过的条目索引(用于重试时跳过验证)
MergeEntryIndices []int `json:"merge_entry_indices,omitempty"` // 需要合并的条目索引(内部使用,用于重试时跳过识别)
MergedCount int `json:"merged_count,omitempty"` // 合并更新的条目数
AddedCount int `json:"added_count,omitempty"` // 新增的条目数
MergeDetails []FAQMergeDetail `json:"merge_details,omitempty"` // 合并详情
Message string `json:"message"` // Status message
Error string `json:"error"` // Error message if failed
CreatedAt int64 `json:"created_at"` // Task creation timestamp
UpdatedAt int64 `json:"updated_at"` // Last update timestamp
DryRun bool `json:"dry_run,omitempty"` // 是否为 dry run 模式
// Result fields (populated when Status == "completed")
ImportMode string `json:"import_mode,omitempty"` // 导入模式:append 或 replace
ImportedAt time.Time `json:"imported_at,omitempty"` // 导入完成时间
DisplayStatus string `json:"display_status,omitempty"` // 显示状态:open 或 close
ProcessingTime int64 `json:"processing_time,omitempty"` // 处理耗时(毫秒)
}
// FAQImportMetadata 存储在Knowledge.Metadata中的FAQ导入任务信息
// Deprecated: Use FAQImportProgress with Redis storage instead
type FAQImportMetadata struct {
ImportProgress int `json:"import_progress"` // 0-100
ImportTotal int `json:"import_total"`
ImportProcessed int `json:"import_processed"`
}
// FAQImportResult 存储FAQ导入完成后的统计结果
// 这个信息是持久化的,不跟随进度状态,直到下次导入时被替换
type FAQImportResult struct {
// 导入统计信息
TotalEntries int `json:"total_entries"` // 总条目数
SuccessCount int `json:"success_count"` // 完全成功的条目数(不包含部分成功/部分失败)
FailedCount int `json:"failed_count"` // 完全失败的条目数
PartialFailedCount int `json:"partial_failed_count"` // 部分失败的条目数(相似问/反例被移除但已导入)
SkippedCount int `json:"skipped_count"` // 跳过的条目数(如重复等)
MergedCount int `json:"merged_count"` // 合并更新的条目数
AddedCount int `json:"added_count"` // 新增的条目数
// 导入模式和时间信息
ImportMode string `json:"import_mode"` // 导入模式:append 或 replace
ImportedAt time.Time `json:"imported_at"` // 导入完成时间
TaskID string `json:"task_id"` // 导入任务ID
// 失败详情URL(失败条目较多时提供下载链接)
FailedEntriesURL string `json:"failed_entries_url,omitempty"` // 失败条目CSV下载URL
// 显示控制
DisplayStatus string `json:"display_status"` // 显示状态:open 或 close
// 额外统计信息
ProcessingTime int64 `json:"processing_time"` // 处理耗时(毫秒)
}
// ToJSON converts the metadata to JSON type.
func (m *FAQImportMetadata) ToJSON() (JSON, error) {
if m == nil {
return nil, nil
}
bytes, err := json.Marshal(m)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
// ToJSON converts the import result to JSON type.
func (r *FAQImportResult) ToJSON() (JSON, error) {
if r == nil {
return nil, nil
}
bytes, err := json.Marshal(r)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
// ParseFAQImportMetadata parses FAQ import metadata from Knowledge.
func ParseFAQImportMetadata(k *Knowledge) (*FAQImportMetadata, error) {
if k == nil || len(k.Metadata) == 0 {
return nil, nil
}
var metadata FAQImportMetadata
if err := json.Unmarshal(k.Metadata, &metadata); err != nil {
return nil, err
}
return &metadata, nil
}
// normalizeQuestionStrings 对问题列表进行归一化处理
// 包括全角转半角、去除末尾标点、合并空格等,同时去重
func normalizeQuestionStrings(values []string) []string {
if len(values) == 0 {
return nil
}
dedup := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
normalized := NormalizeQuestion(v)
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
dedup = append(dedup, normalized)
}
if len(dedup) == 0 {
return nil
}
return dedup
}
// multiSpaceRegex 用于匹配多个连续空白字符
var multiSpaceRegex = regexp.MustCompile(`\s+`)
// urlRegex 用于匹配 URL
var urlRegex = regexp.MustCompile(`https?://[^\s]+`)
// URLNormMode 定义 URL 归一化模式
type URLNormMode int
const (
// URLRemove 完全移除 URL
URLRemove URLNormMode = iota
// URLPlaceholder 替换为占位符 <URL>
URLPlaceholder
// URLKeepDomain 只保留域名
URLKeepDomain
// URLKeepDomainAndPath 保留域名和路径
URLKeepDomainAndPath
)
// t2sConverter 繁体转简体转换器(单例)
var t2sConverter *opencc.OpenCC
func init() {
var err error
t2sConverter, err = opencc.New("t2s") // Traditional to Simplified
if err != nil {
// 初始化失败时使用空转换器,不影响其他功能
t2sConverter = nil
}
}
// NormalizeQuestion 对问题文本进行归一化处理以提高向量匹配命中率
// 处理顺序参考: query = convert_st(trim_url(query.lower().strip().strip("?。,;、:""!?.,;!:'\"")), 1)
// 1. 去除首尾空白
// 2. 移除 URL
// 3. 转小写
// 4. 去除首尾标点
// 5. 繁体转简体
// 6. 全角符号转半角
// 7. 智能空格处理(中文间去空格,英文/数字间保留)
func NormalizeQuestion(q string) string {
q = strings.TrimSpace(q)
if q == "" {
return ""
}
// 1. 移除 URL
q = trimURL(q)
// 2. 转小写(对英文有效)
q = strings.ToLower(q)
// 3. 去除首尾标点符号
q = strings.Trim(q, `?。,;、:""!?.,;!:'""`)
// 4. 繁体转简体
q = toSimplified(q)
// 5. 全角字符转半角
q = toHalfWidth(q)
// 6. 智能空格处理:中文间去空格,英文/数字间保留
q = normalizeSpaces(q)
return strings.TrimSpace(q)
}
// normalizeSpaces 智能处理空格
// 规则:
// - 去除中文语境中的多余空格
// - 保留英文/数字之间的必要空格
// 示例:
// - "怎么 绑定 手机" → "怎么绑定手机"
// - "iphone 15 怎么 激活" → "iphone 15怎么激活"
func normalizeSpaces(s string) string {
// 先合并多个连续空格为单个空格
s = multiSpaceRegex.ReplaceAllString(s, " ")
runes := []rune(s)
if len(runes) == 0 {
return ""
}
var builder strings.Builder
builder.Grow(len(s))
for i := 0; i < len(runes); i++ {
r := runes[i]
// 如果不是空格,直接写入
if r != ' ' {
builder.WriteRune(r)
continue
}
// 处理空格:检查前后字符决定是否保留
// 获取前一个非空字符
var prevRune rune
if i > 0 {
prevRune = runes[i-1]
}
// 获取后一个非空字符
var nextRune rune
for j := i + 1; j < len(runes); j++ {
if runes[j] != ' ' {
nextRune = runes[j]
break
}
}
// 只有当前后都是英文字母或数字时才保留空格
// 其他情况(包括中文)都去除空格
if isASCIIAlphaNum(prevRune) && isASCIIAlphaNum(nextRune) {
builder.WriteRune(' ')
}
// 否则跳过空格
}
return builder.String()
}
// isASCIIAlphaNum 判断是否为 ASCII 字母或数字
func isASCIIAlphaNum(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
}
// trimURL 移除字符串中的 URL(使用默认的 URLRemove 模式)
func trimURL(s string) string {
return NormalizeURL(s, URLRemove)
}
// NormalizeURL 根据指定模式处理文本中的 URL
func NormalizeURL(text string, mode URLNormMode) string {
return urlRegex.ReplaceAllStringFunc(text, func(raw string) string {
switch mode {
case URLRemove:
return ""
case URLPlaceholder:
return "<URL>"
case URLKeepDomain:
domain, _ := parseURL(raw)
if domain != "" {
return domain
}
return "<URL>"
case URLKeepDomainAndPath:
domain, path := parseURL(raw)
if domain != "" {
return domain + path
}
return "<URL>"
default:
return "<URL>"
}
})
}
// parseURL 解析 URL,返回域名和路径
func parseURL(raw string) (domain, path string) {
// 移除协议前缀
u := raw
if strings.HasPrefix(u, "https://") {
u = u[8:]
} else if strings.HasPrefix(u, "http://") {
u = u[7:]
}
// 分离域名和路径
slashIdx := strings.Index(u, "/")
if slashIdx == -1 {
// 没有路径,整个是域名(可能带查询参数)
queryIdx := strings.Index(u, "?")
if queryIdx != -1 {
domain = u[:queryIdx]
} else {
domain = u
}
return domain, ""
}
domain = u[:slashIdx]
path = u[slashIdx:]
// 移除查询参数和片段
if queryIdx := strings.Index(path, "?"); queryIdx != -1 {
path = path[:queryIdx]
}
if fragIdx := strings.Index(path, "#"); fragIdx != -1 {
path = path[:fragIdx]
}
return domain, path
}
// toSimplified 繁体中文转简体中文
func toSimplified(s string) string {
if t2sConverter == nil {
return s
}
result, err := t2sConverter.Convert(s)
if err != nil {
return s
}
return result
}
// toHalfWidth 将全角字符转换为半角字符
// 主要处理:全角空格、全角ASCII字符(包括标点符号)
func toHalfWidth(s string) string {
var builder strings.Builder
builder.Grow(len(s))
for _, r := range s {
switch {
// 全角空格 -> 半角空格
case r == '\u3000':
builder.WriteRune(' ')
// 全角ASCII字符 (!到~,范围 0xFF01-0xFF5E) -> 半角 (0x0021-0x007E)
case r >= 0xFF01 && r <= 0xFF5E:
builder.WriteRune(r - 0xFF01 + 0x21)
// 其他字符保持不变
default:
builder.WriteRune(r)
}
}
return builder.String()
}
// NormalizeQueryText 对搜索查询文本进行归一化处理
// 与 NormalizeQuestion 相同的处理逻辑,用于搜索时对查询文本进行归一化
func NormalizeQueryText(q string) string {
return NormalizeQuestion(q)
}
// IsChineseChar 判断是否为中文字符
func IsChineseChar(r rune) bool {
return unicode.Is(unicode.Han, r)
}
+77
View File
@@ -0,0 +1,77 @@
package types
import "encoding/json"
// FAQChunkSyncPair links source/destination FAQ chunks sharing content_hash.
type FAQChunkSyncPair struct {
SrcChunkID string
DstChunkID string
}
// FAQChunkDiffResult is the output of FAQChunkDiff.
type FAQChunkDiffResult struct {
ChunksToAdd []string
ChunksToDelete []string
MatchedPairs []FAQChunkSyncPair
}
// FAQChunkStatus holds lightweight fields for clone status sync.
type FAQChunkStatus struct {
ID string
TagID string
IsEnabled bool
Flags ChunkFlags
AnswerStrategy AnswerStrategy
Metadata JSON
}
// FAQChunkStatusFieldsEqual compares status fields (tag excluded).
func FAQChunkStatusFieldsEqual(a, b *FAQChunkStatus) bool {
if a == nil || b == nil {
return a == b
}
if a.IsEnabled != b.IsEnabled || a.Flags != b.Flags {
return false
}
return NormalizeAnswerStrategy(a.AnswerStrategy) == NormalizeAnswerStrategy(b.AnswerStrategy)
}
// FAQChunkNeedsStatusSync reports whether a matched pair needs status sync.
func FAQChunkNeedsStatusSync(src, dst *FAQChunkStatus, mappedSrcTagID string) bool {
if src == nil || dst == nil {
return false
}
if mappedSrcTagID != dst.TagID {
return true
}
return !FAQChunkStatusFieldsEqual(src, dst)
}
// NormalizeAnswerStrategy treats empty as "all".
func NormalizeAnswerStrategy(s AnswerStrategy) AnswerStrategy {
if s == "" {
return AnswerStrategyAll
}
return s
}
// PatchFAQAnswerStrategy copies answer_strategy from src metadata onto dst metadata.
func PatchFAQAnswerStrategy(dstMeta, srcMeta JSON) (JSON, error) {
var dstM, srcM FAQChunkMetadata
if len(dstMeta) > 0 {
if err := json.Unmarshal(dstMeta, &dstM); err != nil {
return nil, err
}
}
if len(srcMeta) > 0 {
if err := json.Unmarshal(srcMeta, &srcM); err != nil {
return nil, err
}
}
dstM.AnswerStrategy = srcM.AnswerStrategy
bytes, err := json.Marshal(&dstM)
if err != nil {
return nil, err
}
return JSON(bytes), nil
}
+19
View File
@@ -0,0 +1,19 @@
package types
import "testing"
func TestFAQChunkNeedsStatusSync(t *testing.T) {
src := &FAQChunkStatus{TagID: "a", IsEnabled: true, Flags: ChunkFlagRecommended, AnswerStrategy: AnswerStrategyAll}
dst := &FAQChunkStatus{TagID: "old", IsEnabled: true, Flags: ChunkFlagRecommended, AnswerStrategy: AnswerStrategyAll}
if !FAQChunkNeedsStatusSync(src, dst, "mapped") {
t.Fatal("tag mismatch should need sync")
}
dst.TagID = "mapped"
if FAQChunkNeedsStatusSync(src, dst, "mapped") {
t.Fatal("in sync after tag mapped")
}
dst.IsEnabled = false
if !FAQChunkNeedsStatusSync(src, dst, "mapped") {
t.Fatal("is_enabled mismatch should need sync")
}
}
+180
View File
@@ -0,0 +1,180 @@
package types
import (
"testing"
)
func TestCalculateFAQContentHash_NormalizeIsApplied(t *testing.T) {
// The core bug: CalculateFAQContentHash must normalize the input so that
// sanitized-only data and pre-normalized data produce the same hash.
meta := &FAQChunkMetadata{
StandardQuestion: " 你好,World? ",
SimilarQuestions: []string{"Hello World", "hello world"},
Answers: []string{"answer1"},
AnswerStrategy: AnswerStrategyAll,
Version: 1,
}
// Path 1: what SetFAQMetadata does (normalize first, then hash)
normalized := meta.Normalize()
hashFromNormalized := CalculateFAQContentHash(normalized)
// Path 2: what calculateReplaceOperations does (hash directly from sanitized data)
sanitized := &FAQChunkMetadata{
StandardQuestion: " 你好,World? ",
SimilarQuestions: []string{"Hello World", "hello world"},
Answers: []string{"answer1"},
AnswerStrategy: AnswerStrategyAll,
Version: 1,
}
sanitized.Sanitize()
hashFromSanitized := CalculateFAQContentHash(sanitized)
if hashFromNormalized != hashFromSanitized {
t.Errorf("Hash mismatch between write and read paths:\n write (normalized first): %s\n read (sanitized only): %s",
hashFromNormalized, hashFromSanitized)
}
}
func TestCalculateFAQContentHash_ConsistentViaSetFAQMetadata(t *testing.T) {
// Simulate the full write path then read-path comparison
meta := &FAQChunkMetadata{
StandardQuestion: "如何退款?",
SimilarQuestions: []string{"怎么退款", "退款流程"},
Answers: []string{"请联系客服"},
AnswerStrategy: AnswerStrategyAll,
Version: 1,
Source: "faq",
}
// Write path: SetFAQMetadata stores ContentHash
chunk := &Chunk{}
if err := chunk.SetFAQMetadata(meta); err != nil {
t.Fatalf("SetFAQMetadata failed: %v", err)
}
if chunk.ContentHash == "" {
t.Fatal("SetFAQMetadata did not set ContentHash")
}
// Read path: calculateReplaceOperations calls sanitize + CalculateFAQContentHash
readMeta := &FAQChunkMetadata{
StandardQuestion: "如何退款?",
SimilarQuestions: []string{"怎么退款", "退款流程"},
Answers: []string{"请联系客服"},
AnswerStrategy: AnswerStrategyAll,
Version: 1,
Source: "faq",
}
readMeta.Sanitize()
readHash := CalculateFAQContentHash(readMeta)
if chunk.ContentHash != readHash {
t.Errorf("Hash mismatch between SetFAQMetadata and direct CalculateFAQContentHash:\n SetFAQMetadata: %s\n CalculateFAQContentHash: %s",
chunk.ContentHash, readHash)
}
}
func TestCalculateFAQContentHash_CaseAndPunctuationInvariant(t *testing.T) {
meta1 := &FAQChunkMetadata{
StandardQuestion: "Hello World?",
Answers: []string{"answer"},
}
meta2 := &FAQChunkMetadata{
StandardQuestion: "hello world?",
Answers: []string{"answer"},
}
hash1 := CalculateFAQContentHash(meta1)
hash2 := CalculateFAQContentHash(meta2)
if hash1 != hash2 {
t.Errorf("Hash should be case/punctuation invariant after normalization:\n %q -> %s\n %q -> %s",
meta1.StandardQuestion, hash1, meta2.StandardQuestion, hash2)
}
}
func TestCalculateFAQContentHash_TraditionalSimplifiedInvariant(t *testing.T) {
meta1 := &FAQChunkMetadata{
StandardQuestion: "如何退款",
Answers: []string{"请联系客服"},
}
meta2 := &FAQChunkMetadata{
StandardQuestion: "如何退款", // simplified
Answers: []string{"請聯繫客服"}, // traditional in answers — answers only sanitize, not normalize
}
// Questions should normalize, but answers only sanitize.
// So answers in traditional vs simplified WILL produce different hashes (by design).
// But standard questions with t2s should match.
metaTraditionalQ := &FAQChunkMetadata{
StandardQuestion: "開發環境",
Answers: []string{"answer"},
}
metaSimplifiedQ := &FAQChunkMetadata{
StandardQuestion: "开发环境",
Answers: []string{"answer"},
}
hashTrad := CalculateFAQContentHash(metaTraditionalQ)
hashSimp := CalculateFAQContentHash(metaSimplifiedQ)
if hashTrad != hashSimp {
t.Errorf("Hash should be traditional/simplified invariant for questions:\n traditional: %s\n simplified: %s",
hashTrad, hashSimp)
}
_ = meta1
_ = meta2
}
func TestCalculateFAQContentHash_SortInvariant(t *testing.T) {
meta1 := &FAQChunkMetadata{
StandardQuestion: "问题",
SimilarQuestions: []string{"a", "b", "c"},
Answers: []string{"x", "y", "z"},
}
meta2 := &FAQChunkMetadata{
StandardQuestion: "问题",
SimilarQuestions: []string{"c", "a", "b"},
Answers: []string{"z", "x", "y"},
}
hash1 := CalculateFAQContentHash(meta1)
hash2 := CalculateFAQContentHash(meta2)
if hash1 != hash2 {
t.Errorf("Hash should be order-invariant for similar questions and answers:\n order1: %s\n order2: %s",
hash1, hash2)
}
}
func TestCalculateFAQContentHash_NilAndEmpty(t *testing.T) {
if h := CalculateFAQContentHash(nil); h != "" {
t.Errorf("Expected empty hash for nil, got %s", h)
}
meta := &FAQChunkMetadata{}
h := CalculateFAQContentHash(meta)
if h == "" {
t.Error("Expected non-empty hash for empty metadata (still has delimiters)")
}
}
func TestCalculateFAQContentHash_FullWidthHalfWidthInvariant(t *testing.T) {
metaFull := &FAQChunkMetadata{
StandardQuestion: "Hello World",
Answers: []string{"answer"},
}
metaHalf := &FAQChunkMetadata{
StandardQuestion: "hello world",
Answers: []string{"answer"},
}
hashFull := CalculateFAQContentHash(metaFull)
hashHalf := CalculateFAQContentHash(metaHalf)
if hashFull != hashHalf {
t.Errorf("Hash should be fullwidth/halfwidth invariant:\n fullwidth: %s\n halfwidth: %s",
hashFull, hashHalf)
}
}
@@ -0,0 +1,26 @@
package types
import "testing"
func TestDocumentChunkMetadataQuestionRevision(t *testing.T) {
oldRevision := 1
currentRevision := 2
meta := &DocumentChunkMetadata{
GeneratedQuestionsRevision: oldRevision,
GeneratedQuestions: []GeneratedQuestion{
{ID: "old", Question: "old question"},
{ID: "current", Question: "current question", ContentRevision: &currentRevision},
},
}
questions := meta.GetQuestionStrings()
if len(questions) != 2 {
t.Fatalf("all retrieval questions should remain available, got %v", questions)
}
if !meta.IsQuestionCurrent(meta.GeneratedQuestions[0], oldRevision) {
t.Fatal("legacy question should use the metadata-level revision")
}
if meta.IsQuestionCurrent(meta.GeneratedQuestions[0], currentRevision) {
t.Fatal("legacy question should still be identifiable as based on an older revision")
}
}
@@ -0,0 +1,26 @@
package types
import (
"strings"
"testing"
)
func TestGeneratedQuestionSourceID(t *testing.T) {
chunkID := "135bf11d-c20e-419a-9f3c-5288d6a0516b"
shortQuestionID := "q1722147984000000000"
if got, want := GeneratedQuestionSourceID(chunkID, shortQuestionID), chunkID+"-"+shortQuestionID; got != want {
t.Fatalf("short source ID changed: got %q, want %q", got, want)
}
longQuestionID := "a4fa83a2-0fde-4b85-99bc-4572fa92d51c"
got := GeneratedQuestionSourceID(chunkID, longQuestionID)
if len(got) > maxGeneratedQuestionSourceIDLength {
t.Fatalf("source ID length = %d, want <= %d: %q", len(got), maxGeneratedQuestionSourceIDLength, got)
}
if !strings.HasPrefix(got, chunkID+"-q") {
t.Fatalf("hashed source ID has unexpected format: %q", got)
}
if got != GeneratedQuestionSourceID(chunkID, longQuestionID) {
t.Fatal("source ID hashing is not deterministic")
}
}
+53
View File
@@ -0,0 +1,53 @@
// Package types defines the core data structures and interfaces used throughout the WeKnora system.
package types
import "context"
// Entity represents a node in the knowledge graph extracted from document chunks.
// Each entity corresponds to a meaningful concept, person, place or thing identified in the text.
type Entity struct {
ID string // Unique identifier for the entity
ChunkIDs []string // References to document chunks where this entity appears
Frequency int `json:"-"` // Number of occurrences in the corpus
Degree int `json:"-"` // Number of connections to other entities
Title string `json:"title" jsonschema:"display name of the entity"` // Display name of the entity
Type string `json:"type" jsonschema:"type of the entity"` // Classification of the entity (e.g., person, concept, organization)
Description string `json:"description" jsonschema:"brief explanation or context about the entity"` // Brief explanation or context about the entity
}
// Relationship represents a connection between two entities in the knowledge graph.
// It captures the semantic connection between entities identified in the document chunks.
type Relationship struct {
ID string `json:"-"` // Unique identifier for the relationship
ChunkIDs []string `json:"-"` // References to document chunks where this relationship is established
CombinedDegree int `json:"-"` // Sum of degrees of the connected entities, used for ranking
Weight float64 `json:"-"` // Strength of the relationship based on textual evidence
Source string `json:"source" jsonschema:"ID of the entity where the relationship starts"` // ID of the entity where the relationship starts
Target string `json:"target" jsonschema:"ID of the entity where the relationship ends"` // ID of the entity where the relationship ends
Description string `json:"description" jsonschema:"description of how these entities are related"` // Description of how these entities are related
Strength int `json:"strength" jsonschema:"normalized measure of relationship importance (1-10)"` // Normalized measure of relationship importance (1-10)
}
// GraphBuilder defines the interface for building and querying the knowledge graph.
// It provides methods to construct the graph from document chunks and retrieve related information.
type GraphBuilder interface {
// BuildGraph constructs a knowledge graph from the provided document chunks.
// It extracts entities and relationships, then builds the graph structure.
BuildGraph(ctx context.Context, chunks []*Chunk) error
// GetRelationChunks retrieves the IDs of chunks directly related to the specified chunk.
// The topK parameter limits the number of results returned, based on relationship strength.
GetRelationChunks(chunkID string, topK int) []string
// GetIndirectRelationChunks finds chunk IDs that are indirectly connected to the specified chunk.
// These are "second-degree" connections, useful for expanding the context during retrieval.
GetIndirectRelationChunks(chunkID string, topK int) []string
// GetAllEntities returns all entities currently in the knowledge graph.
// This is primarily used for visualization and diagnostics.
GetAllEntities() []*Entity
// GetAllRelationships returns all relationships currently in the knowledge graph.
// This is primarily used for visualization and diagnostics.
GetAllRelationships() []*Relationship
}
+77
View File
@@ -0,0 +1,77 @@
package types
import (
"database/sql/driver"
"encoding/json"
)
// IndexingStrategy controls which indexing pipelines are active for a knowledge base.
// Each boolean flag independently enables/disables a processing pipeline.
// When a document is uploaded, only the enabled pipelines will run.
type IndexingStrategy struct {
// VectorEnabled enables semantic vector embedding and search
VectorEnabled bool `yaml:"vector_enabled" json:"vector_enabled"`
// KeywordEnabled enables keyword-based (BM25) search
KeywordEnabled bool `yaml:"keyword_enabled" json:"keyword_enabled"`
// WikiEnabled enables automatic wiki page generation from documents
WikiEnabled bool `yaml:"wiki_enabled" json:"wiki_enabled"`
// GraphEnabled enables knowledge graph entity/relation extraction
GraphEnabled bool `yaml:"graph_enabled" json:"graph_enabled"`
}
// DefaultIndexingStrategy returns the default strategy matching the legacy behavior:
// vector and keyword indexing enabled, wiki and graph disabled.
func DefaultIndexingStrategy() IndexingStrategy {
return IndexingStrategy{
VectorEnabled: true,
KeywordEnabled: true,
WikiEnabled: false,
GraphEnabled: false,
}
}
// NeedsEmbedding returns true if any pipeline that requires an embedding model is enabled.
func (s IndexingStrategy) NeedsEmbedding() bool {
return s.VectorEnabled || s.KeywordEnabled
}
// NeedsChunks returns true if any pipeline that requires document chunks is enabled.
// Chunks are needed for vector indexing, keyword indexing, wiki generation, and graph extraction.
func (s IndexingStrategy) NeedsChunks() bool {
return s.VectorEnabled || s.KeywordEnabled || s.WikiEnabled || s.GraphEnabled
}
// HasAnyIndexing returns true if at least one indexing pipeline is enabled.
func (s IndexingStrategy) HasAnyIndexing() bool {
return s.VectorEnabled || s.KeywordEnabled || s.WikiEnabled || s.GraphEnabled
}
// IsZero returns true if the strategy has no pipelines enabled (zero value).
func (s IndexingStrategy) IsZero() bool {
return !s.VectorEnabled && !s.KeywordEnabled && !s.WikiEnabled && !s.GraphEnabled
}
// Value implements the driver.Valuer interface for GORM serialization.
func (s IndexingStrategy) Value() (driver.Value, error) {
return json.Marshal(s)
}
// Scan implements the sql.Scanner interface for GORM deserialization.
// When the database column is NULL (existing rows before migration),
// it returns DefaultIndexingStrategy() for backward compatibility.
func (s *IndexingStrategy) Scan(value interface{}) error {
if value == nil {
*s = DefaultIndexingStrategy()
return nil
}
b, ok := value.([]byte)
if !ok {
*s = DefaultIndexingStrategy()
return nil
}
if err := json.Unmarshal(b, s); err != nil {
*s = DefaultIndexingStrategy()
return nil
}
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/types"
)
// AgentStreamEvent represents a streaming event from the agent
type AgentStreamEvent struct {
Type string `json:"type"` // "thought", "tool_call", "tool_result", "final_answer", "error", "references"
Content string `json:"content"` // Incremental content
Data map[string]interface{} `json:"data"` // Additional structured data
Done bool `json:"done"` // Whether this is the last event
Iteration int `json:"iteration"` // Current iteration number
}
// AgentEngine defines the interface for agent execution engine
type AgentEngine interface {
// Execute executes the agent with conversation history and returns a stream of events
// imageURLs is optional - when provided, images are passed to the LLM as multimodal content
Execute(
ctx context.Context,
sessionID, messageID, query string,
llmContext []chat.Message,
imageURLs ...[]string,
) (*types.AgentState, error)
// SetMemoryPrompt supplies the long-term memory envelope for this run.
// It must be called before Execute; an empty string is a no-op.
SetMemoryPrompt(prompt string)
}
// AgentService defines the interface for agent-related operations
type AgentService interface {
// CreateAgentEngine creates an agent engine with the given configuration and EventBus.
// Conversation history is loaded by the caller (see service.LoadAgentHistory) and
// passed into AgentEngine.Execute; the engine itself is stateless across turns.
CreateAgentEngine(
ctx context.Context,
config *types.AgentConfig,
chatModel chat.Chat,
rerankModel rerank.Reranker,
eventBus *event.EventBus,
sessionID, assistantMessageID string,
) (AgentEngine, error)
// ValidateConfig validates an agent configuration
ValidateConfig(config *types.AgentConfig) error
}
+77
View File
@@ -0,0 +1,77 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
// AuditLogQuery is the cursor + filter set for listing audit log
// entries. AfterID is the last id from the previous page (rows with
// id < AfterID are returned, newest first); 0 means "from the top".
// Limit is capped at 100 inside the repository regardless of caller
// input — keeps unbounded scans off the table.
type AuditLogQuery struct {
AfterID uint64
Limit int
Action types.AuditAction
Outcome types.AuditOutcome
ActorUserID string
ScopeType string
ScopeID string
UnscopedOnly bool
}
// AuditLogRepository is the storage primitive for the audit table.
// All writes are inserts (immutable rows); the only "update" surface
// is none — once written, an entry is permanent.
type AuditLogRepository interface {
Create(ctx context.Context, entry *types.AuditLog) error
List(ctx context.Context, tenantID uint64, q *AuditLogQuery) ([]*types.AuditLog, error)
// CountSinceForDedup is the rate-limit primitive for LogDenied —
// returns the count of matching rows in the trailing window so the
// service can skip writing duplicates. Filter is
// (tenant_id, actor_user_id, action, request_path, created_at >= since).
CountSinceForDedup(
ctx context.Context,
tenantID uint64,
actorUserID string,
action types.AuditAction,
requestPath string,
since time.Time,
) (int64, error)
// DeleteOlderThan removes audit rows whose created_at is strictly
// before cutoff and returns the affected row count. It is the
// retention primitive driven by the daily background sweep.
// Implementations should delete in a single statement (no per-row
// fetch) so the long-tail cost stays at "one DELETE per sweep".
DeleteOlderThan(ctx context.Context, cutoff time.Time) (int64, error)
}
// AuditLogService is the high-level audit API the rest of the codebase
// uses. It owns timestamp defaulting (Log) and rate-limit dedup
// (LogDenied) so callers don't have to think about either.
type AuditLogService interface {
// Log writes a single audit entry. Callers fill TenantID + Action +
// any per-event fields; the service fills CreatedAt if zero.
Log(ctx context.Context, entry *types.AuditLog) error
// LogDenied records a middleware-level reject decision. Subject to
// 1-minute sliding-window dedup keyed by
// (tenant_id, actor_user_id, action, request_path) so a probing
// client cannot flood the table.
LogDenied(
ctx context.Context,
c *gin.Context,
tenantID uint64,
actorUserID, actorRole string,
requiredRole types.TenantRole,
) error
List(ctx context.Context, tenantID uint64, q *AuditLogQuery) ([]*types.AuditLog, error)
// Purge deletes rows whose created_at is strictly older than the
// retention horizon. retentionDays <= 0 makes the call a no-op,
// which keeps the daily sweep cheap when retention is disabled.
// Returns rows deleted; transient repo errors propagate.
Purge(ctx context.Context, retentionDays int) (int64, error)
}
+178
View File
@@ -0,0 +1,178 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// ChunkImageInfo holds (knowledge_id, image_info) pairs for image cleanup before chunk deletion.
type ChunkImageInfo struct {
KnowledgeID string `gorm:"column:knowledge_id"`
ImageInfo string `gorm:"column:image_info"`
}
// ChunkRepository defines the interface for chunk repository operations
type ChunkRepository interface {
// CreateChunks creates chunks
CreateChunks(ctx context.Context, chunks []*types.Chunk) error
// GetChunkByID gets a chunk by id
GetChunkByID(ctx context.Context, tenantID uint64, id string) (*types.Chunk, error)
// GetChunkByIDOnly gets a chunk by id without tenant filter (for permission resolution)
GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error)
// GetChunkBySeqID gets a chunk by seq_id
GetChunkBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.Chunk, error)
// ListChunksByID lists chunks by ids
ListChunksByID(ctx context.Context, tenantID uint64, ids []string) ([]*types.Chunk, error)
// ListChunksByIDOnly lists chunks by ids without tenant filter (for shared KB resolution).
ListChunksByIDOnly(ctx context.Context, ids []string) ([]*types.Chunk, error)
// ListChunksBySeqID lists chunks by seq_ids
ListChunksBySeqID(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.Chunk, error)
// ListChunksByKnowledgeID lists the knowledge's text chunks. Despite the name
// it filters to chunk_type = 'text'; reach for ListChunksByKnowledgeIDAndTypes
// when summary, parent_text or image chunks are needed too.
ListChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error)
// ListChunksByKnowledgeIDAndTypes lists the knowledge's chunks restricted to
// the given chunk types, ordered by chunk_index.
ListChunksByKnowledgeIDAndTypes(
ctx context.Context, tenantID uint64, knowledgeID string, chunkTypes []types.ChunkType,
) ([]*types.Chunk, error)
// ListPagedChunksByKnowledgeID lists paged chunks by knowledge id.
// When tagIDs is non-empty, results are filtered by tag_id (OR semantics).
// knowledgeType: "faq" or "manual" - determines sort order and search behavior
// - FAQ: sorts by updated_at, searchField can be "standard_question", "similar_questions", "answers", or "" for all
// - Document (manual): sorts by chunk_index, keyword searches content only
// sortOrder: "asc" for ascending, default is descending
// searchField: specifies which field to search in (only applicable for FAQ type)
// isEnabled: when non-nil, filters chunks by their enabled state. Agent/model
// consumers must pass true so disabled content never enters model context.
ListPagedChunksByKnowledgeID(
ctx context.Context,
tenantID uint64,
knowledgeID string,
page *types.Pagination,
chunkType []types.ChunkType,
tagIDs []string,
keyword string,
searchField string,
sortOrder string,
knowledgeType string,
isEnabled *bool,
) ([]*types.Chunk, int64, error)
ListChunkByParentID(ctx context.Context, tenantID uint64, parentID string) ([]*types.Chunk, error)
// ListChunksByParentIDs lists chunks whose parent_chunk_id is in the given list
ListChunksByParentIDs(ctx context.Context, tenantID uint64, parentIDs []string) ([]*types.Chunk, error)
// UpdateChunk updates a chunk
UpdateChunk(ctx context.Context, chunk *types.Chunk) error
// CreateChunkRevision stores an immutable snapshot of a superseded revision.
CreateChunkRevision(ctx context.Context, revision *types.ChunkRevision) error
// SaveChunkRevision atomically snapshots the old row and applies the new
// row only when its revision still matches expectedRevision.
SaveChunkRevision(ctx context.Context, chunk *types.Chunk, revision *types.ChunkRevision, expectedRevision int) error
// ListChunkRevisions returns snapshots ordered newest first.
ListChunkRevisions(ctx context.Context, tenantID uint64, chunkID string) ([]*types.ChunkRevision, error)
// GetChunkRevision returns one historical snapshot.
GetChunkRevision(ctx context.Context, tenantID uint64, chunkID string, revision int) (*types.ChunkRevision, error)
// UpdateChunks updates chunks in batch
UpdateChunks(ctx context.Context, chunks []*types.Chunk) error
// SaveChunks persists full chunk objects in a single transaction using GORM Save (UPDATE).
SaveChunks(ctx context.Context, chunks []*types.Chunk) error
// DeleteChunk deletes a chunk
DeleteChunk(ctx context.Context, tenantID uint64, id string) error
// DeleteChunks deletes chunks by IDs in batch
DeleteChunks(ctx context.Context, tenantID uint64, ids []string) error
// DeleteChunksByKnowledgeID deletes chunks by knowledge id
DeleteChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) error
// DeleteByKnowledgeList deletes all chunks for a knowledge list
DeleteByKnowledgeList(ctx context.Context, tenantID uint64, knowledgeIDs []string) error
// ListImageInfoByKnowledgeIDs returns non-empty (knowledge_id, image_info) pairs for image cleanup.
ListImageInfoByKnowledgeIDs(ctx context.Context, tenantID uint64, knowledgeIDs []string) ([]ChunkImageInfo, error)
// MoveChunksByKnowledgeID updates knowledge_base_id for all chunks of a knowledge item
MoveChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string, targetKBID string) error
// DeleteChunksByTagID deletes all chunks with the specified tag ID
// Returns the IDs of deleted chunks for index cleanup
DeleteChunksByTagID(ctx context.Context, tenantID uint64, kbID string, tagID string, excludeIDs []string) ([]string, error)
// CountChunksByKnowledgeBaseID counts the number of chunks in a knowledge base.
CountChunksByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) (int64, error)
// DeleteUnindexedChunks deletes unindexed chunks by knowledge id and chunk index range
DeleteUnindexedChunks(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error)
// ListAllFAQChunksByKnowledgeID lists all FAQ chunks for a knowledge ID
// only ID and ContentHash fields for efficiency
ListAllFAQChunksByKnowledgeID(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error)
// ListAllFAQChunksWithMetadataByKnowledgeBaseID lists all FAQ chunks for a knowledge base ID
// returns ID and Metadata fields for duplicate question checking
ListAllFAQChunksWithMetadataByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) ([]*types.Chunk, error)
// FindFAQChunkWithDuplicateQuestion finds a single FAQ chunk whose standard_question or
// similar_questions overlap with the given question list. Returns nil if no duplicate found.
FindFAQChunkWithDuplicateQuestion(ctx context.Context, tenantID uint64, kbID string, excludeChunkID string, questions []string) (*types.Chunk, error)
// ListAllFAQChunksForExport lists all FAQ chunks for export with full metadata, tag_id, is_enabled, and flags
ListAllFAQChunksForExport(ctx context.Context, tenantID uint64, knowledgeID string) ([]*types.Chunk, error)
// UpdateChunkFlagsBatch updates flags for multiple chunks in batch using a single SQL statement.
// setFlags: map of chunk ID to flags to set (OR operation)
// clearFlags: map of chunk ID to flags to clear (AND NOT operation)
UpdateChunkFlagsBatch(ctx context.Context, tenantID uint64, kbID string, setFlags map[string]types.ChunkFlags, clearFlags map[string]types.ChunkFlags) error
// UpdateChunkFieldsByTagID updates fields for all chunks with the specified tag ID.
// Supports updating is_enabled, flags, and tag_id fields.
// newTagID: if not nil, updates tag_id to this value (empty string means uncategorized)
UpdateChunkFieldsByTagID(ctx context.Context, tenantID uint64, kbID string, tagID string, isEnabled *bool, setFlags types.ChunkFlags, clearFlags types.ChunkFlags, newTagID *string, excludeIDs []string) ([]string, error)
// FAQChunkDiff compares FAQ chunks between two knowledge bases and returns the differences.
FAQChunkDiff(ctx context.Context, srcTenantID uint64, srcKBID string, dstTenantID uint64, dstKBID string) (*types.FAQChunkDiffResult, error)
// ListFAQChunkStatusByIDs loads status fields for FAQ clone sync.
ListFAQChunkStatusByIDs(ctx context.Context, tenantID uint64, ids []string) (map[string]*types.FAQChunkStatus, error)
// ListRecommendedFAQChunks lists FAQ chunks with the recommended flag set.
// Filter by explicitly selected kbIDs, knowledgeIDs, and/or FAQ tagIDs.
// Returns up to `limit` chunks sorted by updated_at descending.
ListRecommendedFAQChunks(ctx context.Context, tenantID uint64, kbIDs []string, knowledgeIDs []string, tagIDs []string, limit int) ([]*types.Chunk, error)
// ListRecentDocumentChunksWithQuestions lists recent document chunks that have generated questions.
// Filter by kbIDs and/or knowledgeIDs. At least one of them must be non-empty.
// Returns up to `limit` chunks sorted by updated_at descending.
ListRecentDocumentChunksWithQuestions(ctx context.Context, tenantID uint64, kbIDs []string, knowledgeIDs []string, limit int) ([]*types.Chunk, error)
}
// ChunkService defines the interface for chunk service operations
type ChunkService interface {
// CreateChunks creates chunks
CreateChunks(ctx context.Context, chunks []*types.Chunk) error
// GetChunkByID gets a chunk by id (uses tenant from context)
GetChunkByID(ctx context.Context, id string) (*types.Chunk, error)
// GetChunkByIDOnly gets a chunk by id without tenant filter (for permission resolution)
GetChunkByIDOnly(ctx context.Context, id string) (*types.Chunk, error)
// ListChunksByKnowledgeID lists chunks by knowledge id
ListChunksByKnowledgeID(ctx context.Context, knowledgeID string) ([]*types.Chunk, error)
// ListPagedChunksByKnowledgeID lists paged chunks by knowledge id
ListPagedChunksByKnowledgeID(
ctx context.Context,
knowledgeID string,
page *types.Pagination,
chunkType []types.ChunkType,
) (*types.PageResult, error)
// UpdateChunk updates a chunk
UpdateChunk(ctx context.Context, chunk *types.Chunk) error
// UpdateChunks updates chunks in batch
UpdateChunks(ctx context.Context, chunks []*types.Chunk) error
// DeleteChunk deletes a chunk
DeleteChunk(ctx context.Context, id string) error
// DeleteChunks deletes chunks by IDs in batch
DeleteChunks(ctx context.Context, ids []string) error
// DeleteChunksByKnowledgeID deletes chunks by knowledge id
DeleteChunksByKnowledgeID(ctx context.Context, knowledgeID string) error
// DeleteByKnowledgeList deletes all chunks for a knowledge list
DeleteByKnowledgeList(ctx context.Context, ids []string) error
// ListChunkByParentID lists chunks by parent id
ListChunkByParentID(ctx context.Context, tenantID uint64, parentID string) ([]*types.Chunk, error)
// GetRepository gets the chunk repository
GetRepository() ChunkRepository
// DeleteGeneratedQuestion deletes a single generated question from a chunk by question ID
// This updates the chunk metadata and removes the corresponding vector index
DeleteGeneratedQuestion(ctx context.Context, chunkID string, questionID string) error
// UpdateDocumentChunk applies a revision-checked edit and synchronizes retrieval indices.
UpdateDocumentChunk(ctx context.Context, chunkID string, content *string, isEnabled *bool, expectedRevision *int) (*types.Chunk, error)
// ListChunkRevisions lists immutable snapshots for a chunk.
ListChunkRevisions(ctx context.Context, chunkID string) ([]*types.ChunkRevision, error)
// RevertDocumentChunk restores a historical revision as a new current revision.
RevertDocumentChunk(ctx context.Context, chunkID string, revision int, expectedRevision *int) (*types.Chunk, error)
// UpsertGeneratedQuestion creates or updates a generated retrieval question.
UpsertGeneratedQuestion(ctx context.Context, chunkID string, questionID string, question string) (*types.GeneratedQuestion, error)
}
+159
View File
@@ -0,0 +1,159 @@
// Package interfaces defines the interface contracts for custom agent management
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// CustomAgentService defines the custom agent service interface
// Provides high-level operations for agent creation, querying, updating, and deletion
type CustomAgentService interface {
// CreateAgent creates a new custom agent
// Parameters:
// - ctx: Context information, carrying request tracking, user identity, etc.
// - agent: Agent object containing basic information and configuration
// Returns:
// - Created agent object (including automatically generated ID)
// - Possible errors such as insufficient permissions, validation errors, etc.
CreateAgent(ctx context.Context, agent *types.CustomAgent) (*types.CustomAgent, error)
// GetAgentByID retrieves agent information by ID (uses tenant from context)
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the agent
// Returns:
// - Agent object, if found (including built-in agents)
// - Possible errors such as not existing, insufficient permissions, etc.
GetAgentByID(ctx context.Context, id string) (*types.CustomAgent, error)
// GetAgentByIDAndTenant reads the current tenant without builtin resolution.
// Cross-tenant organization shares must use AgentShareService.
GetAgentByIDAndTenant(ctx context.Context, id string, tenantID uint64) (*types.CustomAgent, error)
// ListAgents lists all agents under the current tenant (including built-in agents)
// Parameters:
// - ctx: Context information, containing tenant information
// Returns:
// - List of agent objects (built-in agents first, then custom agents sorted by creation time)
// - Possible errors such as insufficient permissions, etc.
ListAgents(ctx context.Context) ([]*types.CustomAgent, error)
// UpdateAgent updates agent information
// Parameters:
// - ctx: Context information
// - agent: Agent object containing update information
// Returns:
// - Updated agent object
// - Possible errors such as not existing, insufficient permissions, cannot modify built-in, etc.
UpdateAgent(ctx context.Context, agent *types.CustomAgent) (*types.CustomAgent, error)
// DeleteAgent deletes an agent
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the agent
// Returns:
// - Possible errors such as not existing, insufficient permissions, cannot delete built-in, etc.
DeleteAgent(ctx context.Context, id string) error
// CopyAgent creates a copy of an existing agent
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the agent to copy
// Returns:
// - The newly created agent copy
// - Possible errors such as not existing, insufficient permissions, etc.
CopyAgent(ctx context.Context, id string) (*types.CustomAgent, error)
// GetSuggestedQuestions returns suggested questions for the agent based on its
// associated knowledge bases. When kbIDs or knowledgeIDs are provided, they override
// the agent's default knowledge base selection.
// Parameters:
// - ctx: Context information
// - agentID: Agent ID
// - kbIDs: Optional knowledge base IDs to override agent config
// - knowledgeIDs: Optional knowledge item IDs to further filter
// - tagScopes: Optional KB-scoped knowledge tags (OR semantics within each KB)
// - limit: Maximum number of questions to return
// Returns:
// - List of suggested questions
// - Possible errors
GetSuggestedQuestions(ctx context.Context, agentID string, kbIDs []string, knowledgeIDs []string, tagScopes []types.TagScope, limit int) ([]types.SuggestedQuestion, error)
// GetKnowledgeSuggestedQuestions returns only knowledge-derived candidates.
// It is independent of whether starter suggestions are enabled and is used
// as a source/fallback for contextual follow-up generation.
GetKnowledgeSuggestedQuestions(ctx context.Context, agentID string, kbIDs []string, knowledgeIDs []string, tagScopes []types.TagScope, limit int) ([]types.SuggestedQuestion, error)
}
// CustomAgentRepository defines the custom agent repository interface
// Responsible for agent data persistence and retrieval
type CustomAgentRepository interface {
// CreateAgent creates an agent record
// Parameters:
// - ctx: Context information
// - agent: Agent object
// Returns:
// - Possible errors such as database connection failure, unique constraint conflicts, etc.
CreateAgent(ctx context.Context, agent *types.CustomAgent) error
// GetAgentByID queries an agent by ID and tenant
// Parameters:
// - ctx: Context information
// - id: Agent ID
// - tenantID: Tenant ID for isolation
// Returns:
// - Agent object, if found
// - Possible errors such as record not existing, database errors, etc.
GetAgentByID(ctx context.Context, id string, tenantID uint64) (*types.CustomAgent, error)
// ListAgentsByTenantID lists all agents for a specific tenant
// Parameters:
// - ctx: Context information
// - tenantID: Tenant ID
// Returns:
// - List of agent objects
// - Possible errors such as database errors, etc.
ListAgentsByTenantID(ctx context.Context, tenantID uint64) ([]*types.CustomAgent, error)
// UpdateAgent updates an agent record
// Parameters:
// - ctx: Context information
// - agent: Agent object containing update information
// Returns:
// - Possible errors such as record not existing, database errors, etc.
UpdateAgent(ctx context.Context, agent *types.CustomAgent) error
// DeleteAgent deletes an agent record
// Parameters:
// - ctx: Context information
// - id: Agent ID
// - tenantID: Tenant ID for isolation (required for composite primary key)
// Returns:
// - Possible errors such as record not existing, database errors, etc.
DeleteAgent(ctx context.Context, id string, tenantID uint64) error
// CountByModelID counts active agents in the tenant whose config references
// the given model ID (chat, rerank, VLM, ASR, query-understand, etc.).
CountByModelID(ctx context.Context, tenantID uint64, modelID string) (int64, error)
// ListModelUsages returns the minimal active agent projections that
// reference the model, with every matching binding merged per object.
// Implementations must cap the result at types.ModelUsageListLimit;
// callers that need the untruncated size should use CountByModelID.
ListModelUsages(ctx context.Context, tenantID uint64, modelID string) ([]types.ModelUsageResource, error)
// CountBySandboxConfigID counts agents pointing at a sandbox config.
//
// Used only to warn the admin which agents reference a config; never use it
// to refuse operations. Agent references are permanent state, so blocking on
// them would make credential rotation impossible.
CountBySandboxConfigID(ctx context.Context, tenantID uint64, configID string) (int64, error)
// ListNamesBySandboxConfigID returns agent names pointing at a sandbox config.
//
// Used only to warn the admin which agents reference a config; never use it
// to refuse operations. Agent references are permanent state, so blocking on
// them would make credential rotation impossible.
ListNamesBySandboxConfigID(ctx context.Context, tenantID uint64, configID string) ([]string, error)
}
+128
View File
@@ -0,0 +1,128 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
// DataSourceService defines the interface for data source management operations
type DataSourceService interface {
// CreateDataSource creates a new data source configuration
CreateDataSource(ctx context.Context, ds *types.DataSource) (*types.DataSource, error)
// GetDataSource retrieves a data source by ID
GetDataSource(ctx context.Context, id string) (*types.DataSource, error)
// ListDataSources lists all data sources for a knowledge base
ListDataSources(ctx context.Context, kbID string) ([]*types.DataSource, error)
// UpdateDataSource updates an existing data source
UpdateDataSource(ctx context.Context, ds *types.DataSource) (*types.DataSource, error)
// DeleteDataSource deletes a data source (soft delete)
DeleteDataSource(ctx context.Context, id string) error
// UpdateDataSourceCredentials replaces the connector credential map.
// DataSource credentials are per-connector atomic — there is no
// individual-field PUT, the whole map gets replaced. Returns the updated
// data source so the caller can re-fetch the redacted shape.
UpdateDataSourceCredentials(
ctx context.Context, id string, credentials map[string]interface{},
) (*types.DataSource, error)
// ClearDataSourceCredentials wipes the connector credential map.
// Idempotent on already-empty credentials.
ClearDataSourceCredentials(ctx context.Context, id string) error
// ValidateConnection tests the connection to an external data source
ValidateConnection(ctx context.Context, dsID string) error
// ValidateCredentials tests connectivity using raw credentials without persisting anything.
// This is used by the frontend "Test Connection" button before creating a data source.
ValidateCredentials(ctx context.Context, connectorType string, credentials map[string]interface{}) error
// ListAvailableResources lists resources available for sync in the external system.
// parentID enables lazy loading: "" lists the top level, a resource ExternalID lists its children.
ListAvailableResources(ctx context.Context, dsID string, parentID string) ([]types.Resource, error)
// ResolveResourceAncestors returns the deduplicated ExternalIDs of every
// ancestor that must be expanded to reveal the given (possibly deeply nested)
// resources in a lazily-loaded picker. Used to restore an existing selection
// when editing a data source.
ResolveResourceAncestors(ctx context.Context, dsID string, resourceIDs []string) ([]string, error)
// ManualSync triggers an immediate sync for a data source
ManualSync(ctx context.Context, dsID string) (*types.SyncLog, error)
// PauseDataSource pauses a data source's scheduled syncs
PauseDataSource(ctx context.Context, id string) error
// ResumeDataSource resumes a paused data source
ResumeDataSource(ctx context.Context, id string) error
// GetSyncLogs retrieves sync history for a data source
GetSyncLogs(ctx context.Context, dsID string, limit int, offset int) ([]*types.SyncLog, error)
// GetSyncLog retrieves a specific sync log entry
GetSyncLog(ctx context.Context, syncLogID string) (*types.SyncLog, error)
// ProcessSync handles the actual sync operation (called by asynq task)
ProcessSync(ctx context.Context, task *asynq.Task) error
}
// DataSourceRepository defines database access patterns for data sources
type DataSourceRepository interface {
// Create inserts a new data source record
Create(ctx context.Context, ds *types.DataSource) error
// FindByID retrieves a data source by ID
FindByID(ctx context.Context, id string) (*types.DataSource, error)
// FindByKnowledgeBase lists all data sources for a knowledge base
FindByKnowledgeBase(ctx context.Context, kbID string) ([]*types.DataSource, error)
// Update updates an existing data source
Update(ctx context.Context, ds *types.DataSource) error
// UpdateSyncState updates only fields produced by a sync run.
UpdateSyncState(ctx context.Context, ds *types.DataSource) error
// Delete performs a soft delete
Delete(ctx context.Context, id string) error
// FindActive retrieves all active data sources (used for scheduling)
FindActive(ctx context.Context) ([]*types.DataSource, error)
}
// SyncLogRepository defines database access patterns for sync logs
type SyncLogRepository interface {
// Create inserts a new sync log entry
Create(ctx context.Context, log *types.SyncLog) error
// FindByID retrieves a sync log by ID
FindByID(ctx context.Context, id string) (*types.SyncLog, error)
// FindByDataSource lists sync logs for a data source with pagination
FindByDataSource(ctx context.Context, dsID string, limit int, offset int) ([]*types.SyncLog, error)
// FindLatest retrieves the most recent sync log for a data source
FindLatest(ctx context.Context, dsID string) (*types.SyncLog, error)
// HasRunningSync checks if a data source has any sync currently in "running" status.
// Used to prevent overlapping sync executions.
HasRunningSync(ctx context.Context, dsID string) (bool, error)
// Update updates an existing sync log entry
Update(ctx context.Context, log *types.SyncLog) error
// UpdateResult updates only fields produced by a sync run.
UpdateResult(ctx context.Context, log *types.SyncLog) error
// CancelPendingByDataSource marks all non-terminal sync logs for a data source as canceled.
CancelPendingByDataSource(ctx context.Context, dsID string) error
// CleanupOldLogs deletes sync logs older than the retention period
CleanupOldLogs(ctx context.Context, retentionDays int) error
}
@@ -0,0 +1,25 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// DocReader is the core interface for reading documents into markdown.
type DocReader interface {
Read(ctx context.Context, req *types.ReadRequest) (*types.ReadResult, error)
}
// DocumentReader extends DocReader with transport lifecycle management
// and remote engine discovery. Used by gRPC/HTTP clients that talk to
// the Python docreader service.
type DocumentReader interface {
DocReader
Reconnect(addr string) error
IsConnected() bool
// ListEngines queries the remote docreader for its registered parser engines.
// Returns engines the remote service supports, allowing auto-discovery of
// newly added engines without Go code changes.
ListEngines(ctx context.Context, overrides map[string]string) ([]types.ParserEngineInfo, error)
}
@@ -0,0 +1,38 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// EmbedChannelRepository persists web embed channel rows.
type EmbedChannelRepository interface {
Create(ctx context.Context, ch *types.EmbedChannel) error
GetByID(ctx context.Context, id string) (*types.EmbedChannel, error)
GetByPublishToken(ctx context.Context, token string) (*types.EmbedChannel, error)
ListByAgent(ctx context.Context, tenantID uint64, agentID string) ([]*types.EmbedChannel, error)
ListByTenant(ctx context.Context, tenantID uint64) ([]*types.EmbedChannel, error)
Update(ctx context.Context, ch *types.EmbedChannel) error
Delete(ctx context.Context, tenantID uint64, id string) error
}
// EmbedChannelService manages web embed channel lifecycle.
type EmbedChannelService interface {
Create(ctx context.Context, tenantID uint64, agentID string, req *types.EmbedChannel) (*types.EmbedChannel, string, error)
ListByAgent(ctx context.Context, tenantID uint64, agentID string) ([]*types.EmbedChannel, error)
ListByTenant(ctx context.Context, tenantID uint64) ([]*types.EmbedChannel, error)
Update(ctx context.Context, tenantID uint64, id string, req *types.EmbedChannel, enabled *bool, showSuggested *bool, allowWebSearch *bool, allowFileUpload *bool, defaultLocale *string, webhookURL *string, webhookSecret *string) (*types.EmbedChannel, error)
Delete(ctx context.Context, tenantID uint64, id string) error
RotateToken(ctx context.Context, tenantID uint64, id string) (*types.EmbedChannel, string, error)
LookupForEmbed(ctx context.Context, channelID, token string) (*types.EmbedChannel, error)
LookupEnabledChannel(ctx context.Context, channelID string) (*types.EmbedChannel, error)
IssueSessionToken(ctx context.Context, channelID string) (sessionToken string, expiresIn int, err error)
IssuePreviewSession(ctx context.Context, tenantID uint64, channelID string) (sessionToken string, expiresIn int, err error)
ResolveSessionToken(ctx context.Context, token string) (channelID string, err error)
PublicConfig(ctx context.Context, ch *types.EmbedChannel) types.EmbedChannelPublicConfig
SuggestedQuestions(ctx context.Context, ch *types.EmbedChannel, limit int) ([]types.SuggestedQuestion, error)
EmbedChunk(ctx context.Context, ch *types.EmbedChannel, chunkID string) (*types.Chunk, error)
EmbedDisplayTitle(ctx context.Context, ch *types.EmbedChannel) string
GetOwnedChannel(ctx context.Context, tenantID uint64, id string) (*types.EmbedChannel, error)
}
+35
View File
@@ -0,0 +1,35 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// EvaluationService defines operations for evaluation tasks
type EvaluationService interface {
// Evaluation starts a new evaluation task
Evaluation(ctx context.Context, datasetID string, knowledgeBaseID string,
chatModelID string, rerankModelID string,
) (*types.EvaluationDetail, error)
// EvaluationResult retrieves evaluation result by task ID
EvaluationResult(ctx context.Context, taskID string) (*types.EvaluationDetail, error)
}
// Metrics defines interface for computing evaluation metrics
type Metrics interface {
// Compute calculates metric score based on input data
Compute(metricInput *types.MetricInput) float64
}
// EvalHook defines interface for evaluation process hooks
type EvalHook interface {
// Handle processes evaluation state change
Handle(ctx context.Context, state types.EvalState, index int, data interface{}) error
}
// DatasetService defines operations for dataset management
type DatasetService interface {
// GetDatasetByID retrieves QA pairs from dataset by ID
GetDatasetByID(ctx context.Context, datasetID string) ([]*types.QAPair, error)
}
+31
View File
@@ -0,0 +1,31 @@
package interfaces
import (
"context"
"io"
"mime/multipart"
)
// FileService is the interface for file services.
// FileService provides methods to save, retrieve, and delete files.
type FileService interface {
// CheckConnectivity verifies that the storage backend is reachable and
// properly configured (e.g. bucket exists, credentials valid).
CheckConnectivity(ctx context.Context) error
// SaveFile saves a file.
SaveFile(ctx context.Context, file *multipart.FileHeader, tenantID uint64, knowledgeID string) (string, error)
// SaveBytes saves bytes data to a file and returns the file path.
// If temp is true, the file will be saved to a temporary storage that may auto-expire.
SaveBytes(ctx context.Context, data []byte, tenantID uint64, fileName string, temp bool) (string, error)
// GetFile retrieves a file.
GetFile(ctx context.Context, filePath string) (io.ReadCloser, error)
// GetFileURL returns a download URL for the file (if supported by the storage backend).
GetFileURL(ctx context.Context, filePath string) (string, error)
// DeleteFile deletes a file.
DeleteFile(ctx context.Context, filePath string) error
// CopyFile copies an existing stored object to a NEW object owned by
// (tenantID, knowledgeID), returning the new provider:// path. The copy is
// independent: deleting the source never affects it. Returns ErrCrossBackendCopy
// when srcPath belongs to a different storage provider than this service.
CopyFile(ctx context.Context, srcPath string, tenantID uint64, knowledgeID string) (string, error)
}
+349
View File
@@ -0,0 +1,349 @@
package interfaces
import (
"context"
"io"
"mime/multipart"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
// KnowledgeService defines the interface for knowledge services.
type KnowledgeService interface {
// CreateKnowledgeFromFile creates knowledge from a file.
// channel identifies the ingestion channel (e.g. "web", "api", "wechat"); empty defaults to "web".
CreateKnowledgeFromFile(
ctx context.Context,
kbID string,
file *multipart.FileHeader,
metadata map[string]string,
enableMultimodel *bool,
customFileName string,
tagIDs []string,
channel string,
processOverrides *types.KnowledgeProcessOverrides,
) (*types.Knowledge, error)
// CreateKnowledgeFromURL creates knowledge from a URL.
// When fileName or fileType is provided (or the URL path has a known file extension),
// the URL is treated as a direct file download instead of a web page crawl.
// channel identifies the ingestion channel; empty defaults to "web".
CreateKnowledgeFromURL(
ctx context.Context,
kbID string,
url string,
fileName string,
fileType string,
enableMultimodel *bool,
title string,
tagIDs []string,
channel string,
processOverrides *types.KnowledgeProcessOverrides,
) (*types.Knowledge, error)
// CreateKnowledgeFromPassage creates knowledge from text passages.
// channel identifies the ingestion channel; empty defaults to "web".
CreateKnowledgeFromPassage(ctx context.Context, kbID string, passage []string, channel string) (*types.Knowledge, error)
// CreateKnowledgeFromPassageSync creates knowledge from text passages and waits until chunks are indexed.
CreateKnowledgeFromPassageSync(ctx context.Context, kbID string, passage []string, channel string) (*types.Knowledge, error)
// CreateKnowledgeFromManual creates or saves manual Markdown knowledge content.
// channel identifies the ingestion channel; empty defaults to "web".
CreateKnowledgeFromManual(
ctx context.Context,
kbID string,
payload *types.ManualKnowledgePayload,
channel string,
) (*types.Knowledge, error)
// GetKnowledgeByID retrieves knowledge by ID (uses tenant from context).
GetKnowledgeByID(ctx context.Context, id string) (*types.Knowledge, error)
// GetKnowledgeByIDOnly retrieves knowledge by ID without tenant filter (for permission resolution).
GetKnowledgeByIDOnly(ctx context.Context, id string) (*types.Knowledge, error)
// GetOwningKBCreatorID resolves a knowledge ID to the CreatorID of its
// owning KnowledgeBase, scoped to the caller's tenant. Used by the
// per-KB ownership lookups in handler/rbac_lookups.go (PR 5, #1303) so
// chunk and knowledge sub-resource routes can inherit the same
// "creator-of-the-KB OR Admin+" gate that KB-level routes already use.
// Returns the underlying repository sentinel errors unchanged so
// callers can map them to middleware.ErrResourceNotFound.
GetOwningKBCreatorID(ctx context.Context, knowledgeID string) (string, error)
// GetKnowledgeBatch retrieves a batch of knowledge by IDs.
GetKnowledgeBatch(ctx context.Context, tenantID uint64, ids []string) ([]*types.Knowledge, error)
// GetKnowledgeBatchWithSharedAccess retrieves knowledge by IDs including items from shared KBs the user has access to.
GetKnowledgeBatchWithSharedAccess(ctx context.Context, tenantID uint64, ids []string) ([]*types.Knowledge, error)
// ListKnowledgeIDsByTagIDs returns document knowledge IDs carrying any of
// the specified KB-local tags.
ListKnowledgeIDsByTagIDs(ctx context.Context, tenantID uint64, kbID string, tagIDs []string) ([]string, error)
// ListKnowledgeByKnowledgeBaseID lists all knowledge under a knowledge base.
ListKnowledgeByKnowledgeBaseID(ctx context.Context, kbID string) ([]*types.Knowledge, error)
// ListPagedKnowledgeByKnowledgeBaseID lists all knowledge under a knowledge base
// with pagination. The filter struct controls optional dimensions (tag, keyword,
// file type, parse status, source channel, updated time range); pass a zero
// struct to disable all filters.
ListPagedKnowledgeByKnowledgeBaseID(
ctx context.Context,
kbID string,
page *types.Pagination,
filter types.KnowledgeListFilter,
) (*types.PageResult, error)
// ListKnowledgeFolderTree returns the folder hierarchy derived from the
// folder_path of every knowledge entry in a knowledge base, with per-folder
// document counts. It powers the document sidebar tree.
ListKnowledgeFolderTree(ctx context.Context, kbID string) (*types.KnowledgeFolderTree, error)
// MoveKnowledgeToFolder re-files knowledge entries under the given folder
// path (empty means the knowledge base top level). Folders are derived from
// the stored paths, so a path that does not exist yet is created implicitly.
MoveKnowledgeToFolder(
ctx context.Context,
kbID string,
ids []string,
folderPath string,
) (int64, error)
// RenameKnowledgeFolder moves a folder and everything below it to a new path.
RenameKnowledgeFolder(ctx context.Context, kbID string, from string, to string) (int64, error)
// DeleteKnowledge deletes knowledge by ID.
DeleteKnowledge(ctx context.Context, id string) error
// DeleteKnowledgeList deletes multiple knowledge entries by IDs.
DeleteKnowledgeList(ctx context.Context, ids []string) error
// GetKnowledgeFile retrieves the file associated with the knowledge.
GetKnowledgeFile(ctx context.Context, id string) (io.ReadCloser, string, error)
// UpdateKnowledge updates knowledge information.
UpdateKnowledge(ctx context.Context, knowledge *types.Knowledge) error
// RegenerateKnowledgeSummary refreshes the document description and summary retrieval chunk.
RegenerateKnowledgeSummary(ctx context.Context, knowledgeID string) (*types.Knowledge, error)
// RequestKnowledgeSummaryRefresh enqueues an async summary refresh.
RequestKnowledgeSummaryRefresh(ctx context.Context, knowledgeID string) error
// RegenerateChunkQuestions rebuilds the auxiliary questions for one current chunk revision.
RegenerateChunkQuestions(ctx context.Context, chunkID string) ([]types.GeneratedQuestion, error)
// UpdateManualKnowledge updates manual Markdown knowledge content.
UpdateManualKnowledge(
ctx context.Context,
knowledgeID string,
payload *types.ManualKnowledgePayload,
) (*types.Knowledge, error)
// ReparseKnowledge deletes existing document content and re-parses the knowledge asynchronously.
// When processOverrides is non-nil, it is validated and persisted to the knowledge metadata
// before re-parsing, letting callers adjust parse config on reparse; nil keeps stored overrides.
ReparseKnowledge(
ctx context.Context,
knowledgeID string,
processOverrides *types.KnowledgeProcessOverrides,
) (*types.Knowledge, error)
// CancelKnowledgeParse marks an in-progress parse as cancelled by the
// user. The knowledge row and any partially written chunks/index are
// kept; downstream queued tasks for the same knowledge are best-effort
// dequeued and active workers are signalled to stop at their next
// checkpoint. Idempotent — returns the existing row when the knowledge
// is already cancelled. Returns an error when the knowledge is in a
// terminal state (completed / failed) or being deleted.
CancelKnowledgeParse(ctx context.Context, knowledgeID string) (*types.Knowledge, error)
// CloneKnowledgeBase clones knowledge to another knowledge base.
CloneKnowledgeBase(ctx context.Context, srcID, dstID string) error
// UpdateImageInfo updates image information for a knowledge chunk.
UpdateImageInfo(ctx context.Context, knowledgeID string, chunkID string, imageInfo string) error
// ListFAQEntries lists FAQ entries under a FAQ knowledge base.
// When tagUUIDs is non-empty, results are filtered by tag UUID on FAQ chunks (OR semantics).
// searchField: specifies which field to search in ("standard_question", "similar_questions", "answers", "" for all)
// sortOrder: "asc" for time ascending (updated_at ASC), default is time descending (updated_at DESC)
ListFAQEntries(
ctx context.Context,
kbID string,
page *types.Pagination,
tagUUIDs []string,
legacyTagSeqID int64,
keyword string,
searchField string,
sortOrder string,
) (*types.PageResult, error)
// UpsertFAQEntries imports or appends FAQ entries asynchronously.
// When DryRun is true, only validates entries without actually importing.
// Returns task ID (Knowledge ID) for tracking import progress.
UpsertFAQEntries(ctx context.Context, kbID string, payload *types.FAQBatchUpsertPayload) (string, error)
// CreateFAQEntry creates a single FAQ entry synchronously.
CreateFAQEntry(ctx context.Context, kbID string, payload *types.FAQEntryPayload) (*types.FAQEntry, error)
// GetFAQEntry retrieves a single FAQ entry by seq_id.
GetFAQEntry(ctx context.Context, kbID string, entrySeqID int64) (*types.FAQEntry, error)
// UpdateFAQEntry updates a single FAQ entry.
UpdateFAQEntry(ctx context.Context, kbID string, entrySeqID int64, payload *types.FAQEntryPayload) (*types.FAQEntry, error)
// AddSimilarQuestions adds similar questions to a FAQ entry.
AddSimilarQuestions(ctx context.Context, kbID string, entrySeqID int64, questions []string) (*types.FAQEntry, error)
// UpdateFAQEntryFieldsBatch updates multiple fields for FAQ entries in batch.
// Supports updating is_enabled, is_recommended, tag_id, and other fields in a single call.
UpdateFAQEntryFieldsBatch(ctx context.Context, kbID string, req *types.FAQEntryFieldsBatchUpdate) error
// DeleteFAQEntries deletes FAQ entries in batch by seq_id.
DeleteFAQEntries(ctx context.Context, kbID string, entrySeqIDs []int64) error
// SearchFAQEntries searches FAQ entries using hybrid search.
SearchFAQEntries(ctx context.Context, kbID string, req *types.FAQSearchRequest) ([]*types.FAQEntry, error)
// ExportFAQEntries exports all FAQ entries for a knowledge base as CSV data.
ExportFAQEntries(ctx context.Context, kbID string) ([]byte, error)
// ExportFAQEntriesJSON exports all FAQ entries as a JSON array compatible with FAQEntryPayload.
ExportFAQEntriesJSON(ctx context.Context, kbID string) ([]byte, error)
// UpdateKnowledgeTagBatch updates tag for document knowledge items in batch.
// authorizedKBID restricts all updates to knowledge items belonging to this KB;
// pass empty string to skip (caller must ensure authorization by other means).
UpdateKnowledgeTagBatch(ctx context.Context, authorizedKBID string, updates map[string][]string) error
// SetKnowledgeTags replaces all tags for a single knowledge entry.
SetKnowledgeTags(ctx context.Context, knowledgeID string, tagIDs []string) error
// GetKnowledgeTags returns tags for multiple knowledge IDs.
GetKnowledgeTags(ctx context.Context, knowledgeIDs []string) (map[string][]*types.KnowledgeTag, error)
// UpdateFAQEntryTagBatch updates tag for FAQ entries in batch.
// Key: entry seq_id, Value: tag seq_id (nil to remove tag)
UpdateFAQEntryTagBatch(ctx context.Context, kbID string, updates map[int64]*int64) error
// GetRepository gets the knowledge repository
GetRepository() KnowledgeRepository
// ProcessManualUpdate handles Asynq manual knowledge update tasks (cleanup + re-indexing)
ProcessManualUpdate(ctx context.Context, t *asynq.Task) error
// ProcessDocument handles Asynq document processing tasks
ProcessDocument(ctx context.Context, t *asynq.Task) error
// ProcessFAQImport handles Asynq FAQ import tasks
ProcessFAQImport(ctx context.Context, t *asynq.Task) error
// ProcessQuestionGeneration handles Asynq question generation tasks
ProcessQuestionGeneration(ctx context.Context, t *asynq.Task) error
// ProcessSummaryGeneration handles Asynq summary generation tasks
ProcessSummaryGeneration(ctx context.Context, t *asynq.Task) error
// ProcessKBClone handles Asynq knowledge base clone tasks
ProcessKBClone(ctx context.Context, t *asynq.Task) error
// ProcessKnowledgeMove handles Asynq knowledge move tasks
ProcessKnowledgeMove(ctx context.Context, t *asynq.Task) error
// ProcessKnowledgeListDelete handles Asynq knowledge list delete tasks
ProcessKnowledgeListDelete(ctx context.Context, t *asynq.Task) error
// ProcessKnowledgeListReparse handles Asynq knowledge list reparse tasks
ProcessKnowledgeListReparse(ctx context.Context, t *asynq.Task) error
// GetKBCloneProgress retrieves the progress of a knowledge base clone task
GetKBCloneProgress(ctx context.Context, taskID string) (*types.KBCloneProgress, error)
// SaveKBCloneProgress saves the progress of a knowledge base clone task
SaveKBCloneProgress(ctx context.Context, progress *types.KBCloneProgress) error
// GetKnowledgeMoveProgress retrieves the progress of a knowledge move task
GetKnowledgeMoveProgress(ctx context.Context, taskID string) (*types.KnowledgeMoveProgress, error)
// SaveKnowledgeMoveProgress saves the progress of a knowledge move task
SaveKnowledgeMoveProgress(ctx context.Context, progress *types.KnowledgeMoveProgress) error
// GetFAQImportProgress retrieves the progress of an FAQ import task
GetFAQImportProgress(ctx context.Context, taskID string) (*types.FAQImportProgress, error)
// UpdateLastFAQImportResultDisplayStatus updates the display status of FAQ import result
UpdateLastFAQImportResultDisplayStatus(ctx context.Context, kbID string, displayStatus string) error
// SearchKnowledge searches knowledge items by keyword across the tenant.
// fileTypes: optional list of file extensions to filter by (e.g., ["csv", "xlsx"])
SearchKnowledge(ctx context.Context, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, int64, error)
// SearchKnowledgeForScopes searches knowledge within the given (tenant_id, kb_id) scopes (e.g. for shared agent context).
SearchKnowledgeForScopes(ctx context.Context, scopes []types.KnowledgeSearchScope, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, int64, error)
}
// KnowledgeRepository defines the interface for knowledge repositories.
type KnowledgeRepository interface {
CreateKnowledge(ctx context.Context, knowledge *types.Knowledge) error
GetKnowledgeByID(ctx context.Context, tenantID uint64, id string) (*types.Knowledge, error)
// GetKnowledgeByIDOnly returns knowledge by ID without tenant filter (for permission resolution).
GetKnowledgeByIDOnly(ctx context.Context, id string) (*types.Knowledge, error)
ListKnowledgeByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) ([]*types.Knowledge, error)
// ListPagedKnowledgeByKnowledgeBaseID lists all knowledge in a knowledge base
// with pagination. The filter struct controls optional dimensions (tag, keyword,
// file type, parse status, source channel, updated time range); pass a zero
// struct to disable all filters.
ListPagedKnowledgeByKnowledgeBaseID(ctx context.Context,
tenantID uint64, kbID string, page *types.Pagination, filter types.KnowledgeListFilter,
) ([]*types.Knowledge, int64, error)
UpdateKnowledge(ctx context.Context, knowledge *types.Knowledge) error
// UpdateKnowledgeBatch updates knowledge items in batch
UpdateKnowledgeBatch(ctx context.Context, knowledgeList []*types.Knowledge) error
DeleteKnowledge(ctx context.Context, tenantID uint64, id string) error
DeleteKnowledgeList(ctx context.Context, tenantID uint64, ids []string) error
GetKnowledgeBatch(ctx context.Context, tenantID uint64, ids []string) ([]*types.Knowledge, error)
// CheckKnowledgeExists checks if knowledge already exists.
// For file types, check by fileHash or (fileName+fileSize).
// For URL types, check by URL.
// Returns whether it exists, the existing knowledge object (if any), and possible error.
CheckKnowledgeExists(
ctx context.Context,
tenantID uint64,
kbID string,
params *types.KnowledgeCheckParams,
) (bool, *types.Knowledge, error)
// ListKnowledgeFolderCounts aggregates the number of knowledge entries
// stored directly in each folder_path of a knowledge base.
ListKnowledgeFolderCounts(
ctx context.Context,
tenantID uint64,
kbID string,
) ([]*types.KnowledgeFolderCount, error)
// UpdateKnowledgeFolderPath files the given entries under folderPath.
UpdateKnowledgeFolderPath(
ctx context.Context,
tenantID uint64,
kbID string,
ids []string,
folderPath string,
) (int64, error)
// RenameKnowledgeFolderPath rewrites folder_path for a folder and all of its
// descendants. Renaming onto an existing path merges the folders.
RenameKnowledgeFolderPath(
ctx context.Context,
tenantID uint64,
kbID string,
from string,
to string,
) (int64, error)
// AminusB returns the IDs of knowledge in A that have no counterpart in B,
// comparing file_hash as a multiset (so duplicate-count differences and
// NULL/empty hashes are handled correctly, letting a clone converge).
AminusB(ctx context.Context, Atenant uint64, A string, Btenant uint64, B string) ([]string, error)
UpdateKnowledgeColumn(ctx context.Context, id string, column string, value interface{}) error
// UpdateKnowledgeColumns updates multiple columns of a knowledge row in a single
// statement so callers that flip several related fields (e.g. parse_status +
// error_message) cannot leave the row in a half-updated state.
UpdateKnowledgeColumns(ctx context.Context, id string, values map[string]interface{}) error
// UpdateActiveDeletingKnowledgeColumns updates an active, non-deleted knowledge row
// only when it is still in the transient deleting state.
UpdateActiveDeletingKnowledgeColumns(ctx context.Context, id string, values map[string]interface{}) (bool, error)
// FinalizeSubtask atomically decrements pending_subtasks_count for the
// given knowledge and promotes parse_status from "finalizing" to
// "completed" when the count reaches zero. Returns the post-decrement
// count, whether this caller's UPDATE was the one that promoted the
// row, and any error.
FinalizeSubtask(ctx context.Context, id string) (int, bool, error)
// SetFinalizing atomically transitions a row from "processing" to
// "finalizing" and writes the initial pending_subtasks_count. Returns
// whether the transition took place (false when the row's parse_status
// was no longer "processing", e.g. user cancelled / deleted in flight).
SetFinalizing(ctx context.Context, id string, expectedSubtasks int) (bool, error)
// CountKnowledgeByKnowledgeBaseID counts the number of knowledge items in a knowledge base.
CountKnowledgeByKnowledgeBaseID(ctx context.Context, tenantID uint64, kbID string) (int64, error)
// CountKnowledgeByStatus counts the number of knowledge items with the specified parse status.
CountKnowledgeByStatus(ctx context.Context, tenantID uint64, kbID string, parseStatuses []string) (int64, error)
// SearchKnowledge searches knowledge items by keyword across the tenant.
// fileTypes: optional list of file extensions to filter by (e.g., ["csv", "xlsx"])
SearchKnowledge(ctx context.Context, tenantID uint64, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, error)
// FindByMetadataKey finds a knowledge item by a key-value pair in the metadata JSON column.
// Used by data source sync to locate existing items by external_id.
FindByMetadataKey(ctx context.Context, tenantID uint64, kbID string, key string, value string) (*types.Knowledge, error)
// FindByMetadataKeyPrefix finds knowledge items whose metadata[key] starts
// with the given prefix. Used to sweep an external node's attachment sub-items.
FindByMetadataKeyPrefix(ctx context.Context, tenantID uint64, kbID string, key string, prefix string) ([]*types.Knowledge, error)
// FindByDataSourceExternalID finds a knowledge item owned by one data source
// and identified by the source's external item ID.
FindByDataSourceExternalID(
ctx context.Context, tenantID uint64, kbID, dataSourceID, externalID string,
) (*types.Knowledge, error)
// HardDeleteKnowledge physically removes a row after DeleteKnowledge's soft-delete
// cascade. Sync-internal deletions use this so rows never become tombstones.
HardDeleteKnowledge(ctx context.Context, tenantID uint64, id string) error
// HardDeleteKnowledgeList is the batch counterpart of HardDeleteKnowledge.
HardDeleteKnowledgeList(ctx context.Context, tenantID uint64, ids []string) error
// SearchKnowledgeInScopes searches knowledge items by keyword within the given (tenant_id, kb_id) scopes (own + shared).
SearchKnowledgeInScopes(ctx context.Context, scopes []types.KnowledgeSearchScope, keyword string, offset, limit int, fileTypes []string) ([]*types.Knowledge, bool, int64, error)
// ListIDsByTagIDs returns all knowledge IDs that have any of the specified tag IDs (OR semantics).
ListIDsByTagIDs(ctx context.Context, tenantID uint64, kbID string, tagIDs []string) ([]string, error)
// SetKnowledgeTags replaces all tags for a single knowledge entry (deletes old, inserts new).
SetKnowledgeTags(ctx context.Context, knowledgeID string, tagIDs []string) error
// AddKnowledgeTagRelations attaches tags without removing existing ones,
// after validating that the knowledge and every tag belong to the given
// tenant and knowledge base. Duplicate deliveries are idempotent.
AddKnowledgeTagRelations(
ctx context.Context,
tenantID uint64,
kbID, knowledgeID string,
tagIDs []string,
) error
// GetKnowledgeTags returns tags for multiple knowledge IDs.
GetKnowledgeTags(ctx context.Context, knowledgeIDs []string) (map[string][]*types.KnowledgeTag, error)
// DeleteKnowledgeTagRelations deletes all tag relations for a knowledge entry.
DeleteKnowledgeTagRelations(ctx context.Context, knowledgeID string) error
}
+252
View File
@@ -0,0 +1,252 @@
// Package interfaces defines the interface contracts between different system components
// Through interface definitions, business logic can be decoupled from specific implementations,
// improving code testability and maintainability
// Knowledge base related interfaces are used to manage knowledge base resources and their contents
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
"gorm.io/gorm"
)
// KnowledgeBaseService defines the knowledge base service interface
// Provides high-level operations for knowledge base creation, querying, updating, deletion, and content searching
type KnowledgeBaseService interface {
// CreateKnowledgeBase creates a new knowledge base
// Parameters:
// - ctx: Context information, carrying request tracking, user identity, etc.
// - kb: Knowledge base object containing basic information
// Returns:
// - Created knowledge base object (including automatically generated ID)
// - Possible errors such as insufficient permissions, duplicate names, etc.
CreateKnowledgeBase(ctx context.Context, kb *types.KnowledgeBase) (*types.KnowledgeBase, error)
// GetKnowledgeBaseByID retrieves knowledge base information by ID
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the knowledge base
// Returns:
// - Knowledge base object, if found
// - Possible errors such as not existing, insufficient permissions, etc.
GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error)
// GetKnowledgeBaseByIDOnly retrieves knowledge base by ID without tenant filter
// Used for cross-tenant shared KB access where permission is checked elsewhere
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the knowledge base
// Returns:
// - Knowledge base object, if found
// - Possible errors such as not existing, etc.
GetKnowledgeBaseByIDOnly(ctx context.Context, id string) (*types.KnowledgeBase, error)
// GetKnowledgeBasesByIDsOnly retrieves knowledge bases by IDs without tenant filter (batch).
GetKnowledgeBasesByIDsOnly(ctx context.Context, ids []string) ([]*types.KnowledgeBase, error)
// FillKnowledgeBaseCounts fills KnowledgeCount, ChunkCount, IsProcessing, ProcessingCount for the given KB (uses kb.TenantID).
FillKnowledgeBaseCounts(ctx context.Context, kb *types.KnowledgeBase) error
// ListKnowledgeBases lists all knowledge bases under the current tenant
// Parameters:
// - ctx: Context information, containing tenant information
// Returns:
// - List of knowledge base objects
// - Possible errors such as insufficient permissions, etc.
ListKnowledgeBases(ctx context.Context) ([]*types.KnowledgeBase, error)
// ListKnowledgeBasesByTenantID lists all knowledge bases for a specific tenant (e.g. for shared agent context).
ListKnowledgeBasesByTenantID(ctx context.Context, tenantID uint64) ([]*types.KnowledgeBase, error)
// UpdateKnowledgeBase updates knowledge base information
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the knowledge base
// - name: New knowledge base name
// - description: New knowledge base description
// - config: Knowledge base configuration, including chunking strategy, vectorization settings, etc.
// Returns:
// - Updated knowledge base object
// - Possible errors such as not existing, insufficient permissions, etc.
UpdateKnowledgeBase(ctx context.Context,
id string, name string, description string, config *types.KnowledgeBaseConfig,
) (*types.KnowledgeBase, error)
// DeleteKnowledgeBase deletes a knowledge base
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the knowledge base
// Returns:
// - Possible errors such as not existing, insufficient permissions, etc.
DeleteKnowledgeBase(ctx context.Context, id string) error
// TogglePinKnowledgeBase toggles the pin status of a knowledge base
TogglePinKnowledgeBase(ctx context.Context, id string) (*types.KnowledgeBase, error)
// HybridSearch performs hybrid search (vector + keywords) in the knowledge base
// Parameters:
// - ctx: Context information
// - id: Unique identifier of the knowledge base
// - params: Search parameters, including query text, thresholds, etc.
// Returns:
// - List of search results, sorted by relevance
// - Possible errors such as not existing, insufficient permissions, search engine errors, etc.
HybridSearch(ctx context.Context, id string, params types.SearchParams) ([]*types.SearchResult, error)
// GetQueryEmbedding computes the query embedding using the embedding model
// associated with the given knowledge base. This allows callers to pre-compute
// and reuse embeddings across multiple KBs that share the same model.
GetQueryEmbedding(ctx context.Context, kbID string, queryText string) ([]float32, error)
// ResolveEmbeddingModelKeys resolves embedding model IDs to their actual
// model identity key (name + endpoint). KBs using the same underlying model
// across different tenants will share the same key, enabling optimal grouping.
// Returns a map from KB ID to model identity key string.
ResolveEmbeddingModelKeys(ctx context.Context, kbs []*types.KnowledgeBase) map[string]string
// CopyKnowledgeBase copies a knowledge base
// Parameters:
// - ctx: Context information
// - sourceID: Source knowledge base ID
// - targetID: Target knowledge base ID
// Returns:
// - Copied knowledge base object
// - Possible errors such as not existing, insufficient permissions, etc.
CopyKnowledgeBase(ctx context.Context, src string, dst string) (*types.KnowledgeBase, *types.KnowledgeBase, error)
// DuplicateKnowledgeBase creates a new settings-only knowledge base duplicate.
// It does not copy knowledge entries, chunks, FAQ rows, wiki pages, indexes,
// data sources, shares, pins, or task state. A new UUID is always generated.
DuplicateKnowledgeBase(ctx context.Context, src string) (*types.KnowledgeBase, error)
// GetRepository gets the knowledge base repository
// Parameters:
// - ctx: Context with authentication and request information
//
// Returns:
// - interfaces.KnowledgeBaseRepository: Knowledge base repository
GetRepository() KnowledgeBaseRepository
// ProcessKBDelete handles async knowledge base deletion task
// Parameters:
// - ctx: Context information
// - t: Asynq task containing KBDeletePayload
// Returns:
// - Possible errors during deletion
ProcessKBDelete(ctx context.Context, t *asynq.Task) error
}
// KnowledgeBaseRepository defines the knowledge base repository interface
// Responsible for knowledge base data persistence and retrieval,
// serving as a bridge between the service layer and data storage
type KnowledgeBaseRepository interface {
// CreateKnowledgeBase creates a knowledge base record
// Parameters:
// - ctx: Context information
// - kb: Knowledge base object
// Returns:
// - Possible errors such as database connection failure, unique constraint conflicts, etc.
CreateKnowledgeBase(ctx context.Context, kb *types.KnowledgeBase) error
// GetKnowledgeBaseByID queries a knowledge base by ID
// Parameters:
// - ctx: Context information
// - id: Knowledge base ID
// Returns:
// - Knowledge base object, if found
// - Possible errors such as record not existing, database errors, etc.
GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error)
// GetKnowledgeBaseByIDAndTenant queries a knowledge base by ID scoped to a tenant.
// Returns ErrKnowledgeBaseNotFound if the KB does not exist or does not belong to the tenant.
// Parameters:
// - ctx: Context information
// - id: Knowledge base ID
// - tenantID: Tenant ID (enforces tenant isolation)
// Returns:
// - Knowledge base object, if found and owned by tenant
// - Possible errors such as record not existing or wrong tenant, database errors, etc.
GetKnowledgeBaseByIDAndTenant(ctx context.Context, id string, tenantID uint64) (*types.KnowledgeBase, error)
// GetKnowledgeBaseByIDs queries knowledge bases by multiple IDs
// Parameters:
// - ctx: Context information
// - ids: List of knowledge base IDs
// Returns:
// - List of knowledge base objects
// - Possible errors such as database errors, etc.
GetKnowledgeBaseByIDs(ctx context.Context, ids []string) ([]*types.KnowledgeBase, error)
// ListKnowledgeBases lists all knowledge bases in the system
// Parameters:
// - ctx: Context information
// Returns:
// - List of knowledge base objects
// - Possible errors such as database errors, etc.
ListKnowledgeBases(ctx context.Context) ([]*types.KnowledgeBase, error)
// ListKnowledgeBasesByTenantID lists all knowledge bases for a specific tenant
// Parameters:
// - ctx: Context information
// - tenantID: Tenant ID
// Returns:
// - List of knowledge base objects
// - Possible errors such as database errors, etc.
ListKnowledgeBasesByTenantID(ctx context.Context, tenantID uint64) ([]*types.KnowledgeBase, error)
// UpdateKnowledgeBase updates a knowledge base record
// Parameters:
// - ctx: Context information
// - kb: Knowledge base object containing update information
// Returns:
// - Possible errors such as record not existing, database errors, etc.
UpdateKnowledgeBase(ctx context.Context, kb *types.KnowledgeBase) error
// DeleteKnowledgeBase deletes a knowledge base record
// Parameters:
// - ctx: Context information
// - id: Knowledge base ID
// Returns:
// - Possible errors such as record not existing, database errors, etc.
DeleteKnowledgeBase(ctx context.Context, id string) error
// CleanupDeletedKnowledgeBaseCategories retries category cleanup after soft deletion.
CleanupDeletedKnowledgeBaseCategories(ctx context.Context, id string) error
// CountByVectorStoreID counts active KBs bound to the given vector store
// within a tenant scope. Accepts a *gorm.DB handle so callers can share a
// transaction (e.g., the VectorStore delete guard's row-lock context) or
// run standalone (pass nil → uses the repository's default db).
//
// The soft-delete filter is applied automatically by the gorm.DeletedAt
// scope on KnowledgeBase; implementations MUST NOT add an explicit
// `deleted_at IS NULL` predicate (avoids divergence with the auto-scope).
CountByVectorStoreID(ctx context.Context, db *gorm.DB, tenantID uint64, storeID string) (int64, error)
// CountByModelID counts active KBs in the tenant that reference the given
// model ID in any model-binding field (embedding, summary, VLM, ASR, etc.).
CountByModelID(ctx context.Context, tenantID uint64, modelID string) (int64, error)
// ListModelUsages returns the minimal active KB projections that reference
// the model, with every matching binding merged per object. Implementations
// must cap the result at types.ModelUsageListLimit; callers that need the
// untruncated size should use CountByModelID.
ListModelUsages(ctx context.Context, tenantID uint64, modelID string) ([]types.ModelUsageResource, error)
// SetUserKBPin inserts or removes a row in user_kb_pins for the given
// (tenant, user, kb) triple. Returns the resulting pinned_at (nil when
// pinned=false) and an error. The tenant_id is captured to support
// efficient "wipe a tenant" cleanups even though (user_id, kb_id)
// alone would be unique in practice.
SetUserKBPin(
ctx context.Context, tenantID uint64, userID string, kbID string, pinned bool,
) (pinnedAt *time.Time, err error)
// ListUserKBPinIDs returns the kb_id → pinned_at map of every KB the
// given user has personally pinned in this tenant. Used by the list
// path to stamp KnowledgeBase.IsPinned / PinnedAt without a per-row
// roundtrip.
ListUserKBPinIDs(
ctx context.Context, tenantID uint64, userID string,
) (map[string]time.Time, error)
}
+64
View File
@@ -0,0 +1,64 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPOAuthRepository persists OAuth clients (per service) and tokens
// (per principal + service) for the MCP OAuth2 authorization-code flow.
type MCPOAuthRepository interface {
// GetClient returns the registered OAuth client for a service, or
// (nil, nil) when none has been registered yet.
GetClient(ctx context.Context, tenantID uint64, serviceID string) (*types.MCPOAuthClient, error)
// SaveClient creates or updates the registered OAuth client for a service.
SaveClient(ctx context.Context, client *types.MCPOAuthClient) error
// DeleteClient removes the registered OAuth client for a service.
DeleteClient(ctx context.Context, tenantID uint64, serviceID string) error
// GetToken returns the stored token for (tenant, user, service), or
// (nil, nil) when the user has not authorized yet.
GetToken(ctx context.Context, tenantID uint64, userID, serviceID string) (*types.MCPOAuthToken, error)
// GetTokenForPrincipal returns the stored token for (tenant, principal, service),
// or (nil, nil) when the principal has not authorized yet.
GetTokenForPrincipal(
ctx context.Context, tenantID uint64, principal types.Principal, serviceID string,
) (*types.MCPOAuthToken, error)
// SaveToken creates or updates the per-user token for a service.
SaveToken(ctx context.Context, token *types.MCPOAuthToken) error
// SaveTokenForPrincipal creates or updates the per-principal token for a service.
SaveTokenForPrincipal(ctx context.Context, token *types.MCPOAuthToken) error
// DeleteToken removes the per-user token for a service (revoke /
// re-authorize).
DeleteToken(ctx context.Context, tenantID uint64, userID, serviceID string) error
// DeleteTokenForPrincipal removes the per-principal token for a service.
DeleteTokenForPrincipal(ctx context.Context, tenantID uint64, principal types.Principal, serviceID string) error
// TryAcquireTokenRefreshLease atomically claims the right to use a refresh
// token. This prevents concurrent instances from presenting the same
// rotating refresh token to the authorization server.
TryAcquireTokenRefreshLease(
ctx context.Context,
tenantID uint64,
principal types.Principal,
serviceID, leaseID string,
leaseUntil time.Time,
) (bool, error)
// ReleaseTokenRefreshLease releases a lease only when leaseID still owns it.
ReleaseTokenRefreshLease(
ctx context.Context,
tenantID uint64,
principal types.Principal,
serviceID, leaseID string,
) error
}
+85
View File
@@ -0,0 +1,85 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPServiceRepository defines the interface for MCP service data access
type MCPServiceRepository interface {
// Create creates a new MCP service
Create(ctx context.Context, service *types.MCPService) error
// GetByID retrieves an MCP service by ID and tenant ID
GetByID(ctx context.Context, tenantID uint64, id string) (*types.MCPService, error)
// List retrieves all MCP services for a tenant
List(ctx context.Context, tenantID uint64) ([]*types.MCPService, error)
// ListEnabled retrieves all enabled MCP services for a tenant
ListEnabled(ctx context.Context, tenantID uint64) ([]*types.MCPService, error)
// ListByIDs retrieves MCP services by multiple IDs for a tenant
ListByIDs(ctx context.Context, tenantID uint64, ids []string) ([]*types.MCPService, error)
// Update updates an MCP service
Update(ctx context.Context, service *types.MCPService) error
// Delete deletes an MCP service (soft delete)
Delete(ctx context.Context, tenantID uint64, id string) error
}
// MCPServiceService defines the interface for MCP service business logic
type MCPServiceService interface {
// CreateMCPService creates a new MCP service
CreateMCPService(ctx context.Context, service *types.MCPService) error
// GetMCPServiceByID retrieves an MCP service by ID
GetMCPServiceByID(ctx context.Context, tenantID uint64, id string) (*types.MCPService, error)
// ListMCPServices lists all MCP services for a tenant
ListMCPServices(ctx context.Context, tenantID uint64) ([]*types.MCPService, error)
// ListMCPServicesByIDs retrieves multiple MCP services by IDs
ListMCPServicesByIDs(ctx context.Context, tenantID uint64, ids []string) ([]*types.MCPService, error)
// UpdateMCPService updates an MCP service. updateFields records presence for
// scalar fields whose zero values cannot represent omission. Supported keys
// are "name", "description", and "enabled"; a nil map means none of those
// scalar fields were provided.
UpdateMCPService(
ctx context.Context,
service *types.MCPService,
updateFields map[string]bool,
) error
// DeleteMCPService deletes an MCP service
DeleteMCPService(ctx context.Context, tenantID uint64, id string) error
// TestMCPService tests the connection to an MCP service and returns available tools/resources
TestMCPService(ctx context.Context, tenantID uint64, id string) (*types.MCPTestResult, error)
// GetMCPServiceTools retrieves the list of tools from an MCP service
GetMCPServiceTools(ctx context.Context, tenantID uint64, id string) ([]*types.MCPTool, error)
// GetMCPServiceResources retrieves the list of resources from an MCP service
GetMCPServiceResources(ctx context.Context, tenantID uint64, id string) ([]*types.MCPResource, error)
// UpdateMCPCredentials writes one or more credential fields on the auth
// config. Nil pointer means "do not touch this field". Returns the updated
// service (with current AuthConfig) so the handler can derive the
// configured/not-configured metadata for the response.
//
// Implementations MUST close any active MCP client connection for this
// service so the next upstream call reconnects with the new credential.
UpdateMCPCredentials(
ctx context.Context, tenantID uint64, id string, apiKey *string, token *string,
) (*types.MCPService, error)
// ClearMCPCredential removes a single credential field. field must be
// "api_key" or "token"; other values must be rejected by the caller.
// Implementations MUST close any active MCP client connection for this
// service. Clearing a field that is already empty is a no-op (no error).
ClearMCPCredential(ctx context.Context, tenantID uint64, id, field string) error
}
@@ -0,0 +1,21 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// MCPToolApprovalRepository persists per-tool approval requirements.
type MCPToolApprovalRepository interface {
ListByService(ctx context.Context, tenantID uint64, serviceID string) ([]*types.MCPToolApproval, error)
IsRequired(ctx context.Context, tenantID uint64, serviceID, toolName string) (bool, error)
Upsert(ctx context.Context, row *types.MCPToolApproval) error
}
// MCPToolApprovalService is the business layer for MCP tool approval flags.
type MCPToolApprovalService interface {
ListByService(ctx context.Context, tenantID uint64, serviceID string) ([]*types.MCPToolApproval, error)
SetRequireApproval(ctx context.Context, tenantID uint64, serviceID, toolName string, require bool) error
IsRequired(ctx context.Context, tenantID uint64, serviceID, toolName string) (bool, error)
}
+325
View File
@@ -0,0 +1,325 @@
package interfaces
import (
"context"
"errors"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
// MemoryScope is the resolved (workspace, principal) pair a memory operation
// runs under. It is always derived from the request context so no caller can
// address another user's memory by passing an id.
type MemoryScope struct {
TenantID uint64
SubjectID string
}
func (s MemoryScope) Valid() bool {
return s.TenantID > 0 && s.SubjectID != ""
}
// ErrMemoryItemNotPending rejects stale review actions without reviving history.
var ErrMemoryItemNotPending = errors.New("memory: item is not awaiting confirmation")
// MemoryRepository is the storage contract for long-term memory. Every method
// takes an explicit scope rather than reading it from ctx, so a background
// worker cannot accidentally operate on whatever the ambient context happens
// to hold.
type MemoryRepository interface {
// GetSubject returns the memory space, or (nil, nil) when it does not exist.
GetSubject(ctx context.Context, scope MemoryScope) (*types.MemorySubject, error)
// EnsureSubject returns the memory space, creating it on first use.
EnsureSubject(ctx context.Context, scope MemoryScope) (*types.MemorySubject, error)
// UpdateSubjectEnabled flips the per-user opt out.
UpdateSubjectEnabled(ctx context.Context, scope MemoryScope, enabled bool) error
// UpdateSubjectBlock stores the rendered resident block and item count.
UpdateSubjectBlock(ctx context.Context, scope MemoryScope, block string, itemCount int) error
// EnqueuePendingSession records that a session has turns past the cursor
// and claims the in-flight slot when no run is already scheduled. It
// returns the subject as it was before the update plus whether this call
// is the one responsible for enqueuing the task, so a burst of turns
// produces exactly one task and never a dropped turn.
EnqueuePendingSession(
ctx context.Context, scope MemoryScope, sessionID string, inFlightTimeout time.Duration, turnStart ...time.Time,
) (*types.MemorySubject, bool, error)
// ClaimPendingSessions snapshots the durable queue and current cursor.
// Entries remain pending until successful completion acknowledges them.
ClaimPendingSessions(ctx context.Context, scope MemoryScope) ([]string, types.MemorySessionCursors, error)
// FinishExtraction advances the watermark and releases the in-flight slot.
// A zero cursor leaves the watermark untouched.
FinishExtraction(ctx context.Context, scope MemoryScope, cursors types.MemorySessionCursors, completed ...string) error
// ReleaseExtractionSlot clears the in-flight marker without advancing the
// watermark, used when a run ends early.
ReleaseExtractionSlot(ctx context.Context, scope MemoryScope) error
ReserveExtraction(ctx context.Context, scope MemoryScope, payload types.MemoryExtractPayload, delay, lease time.Duration) (*types.MemoryExtractPayload, error)
StartExtraction(ctx context.Context, scope MemoryScope, attemptID string, lease time.Duration) (bool, error)
FailExtraction(ctx context.Context, scope MemoryScope, attemptID string) error
ListDueExtractions(ctx context.Context, limit int) ([]*types.MemorySubject, error)
DeferExtraction(ctx context.Context, scope MemoryScope, delay time.Duration) error
PauseExtraction(ctx context.Context, scope MemoryScope, attemptID string, delay time.Duration) error
ResetExhaustedExtraction(ctx context.Context, scope MemoryScope) (bool, error)
// CreateItem inserts one memory item.
CreateItem(ctx context.Context, item *types.MemoryItem) error
// GetItem returns one item inside the scope, or (nil, nil) when absent.
GetItem(ctx context.Context, scope MemoryScope, id string) (*types.MemoryItem, error)
// ListActiveByKinds returns active items of the given kinds, newest first.
ListActiveByKinds(ctx context.Context, scope MemoryScope, kinds []string, limit int) ([]*types.MemoryItem, error)
// ListActiveResident returns the items that belong in the always-injected
// block: stable traits, plus anything the user explicitly asked to keep.
ListActiveResident(ctx context.Context, scope MemoryScope, limit int) ([]*types.MemoryItem, error)
// ListItems returns items for the memory manager, filtered by status.
ListItems(ctx context.Context, scope MemoryScope, status string, limit, offset int) ([]*types.MemoryItem, int64, error)
// FindActiveByKey returns the active item occupying a topic key, if any.
FindActiveByKey(ctx context.Context, scope MemoryScope, normalizedKey string) (*types.MemoryItem, error)
// UpdateItemContent rewrites an item edited in the memory manager.
UpdateItemContent(ctx context.Context, scope MemoryScope, id, content, normalizedKey string, importance int) error
// SupersedeItem marks an outdated item as replaced by another one.
SupersedeItem(ctx context.Context, scope MemoryScope, id, supersededBy string) error
// DeleteItem physically removes one item. Forgetting means forgetting.
DeleteItem(ctx context.Context, scope MemoryScope, id string) error
// DeleteAll removes every item in the scope and returns how many were removed.
DeleteAll(ctx context.Context, scope MemoryScope) (int64, error)
// TouchUsed records that items were injected into a turn.
TouchUsed(ctx context.Context, scope MemoryScope, ids []string) error
// AddTombstone records that a statement was deliberately forgotten. Only
// the topic, a fingerprint and the originating message are stored, never
// the statement itself.
AddTombstone(ctx context.Context, scope MemoryScope, topic, fingerprint, sourceMessageID string) error
// HasTombstoneForMessage reports whether a memory derived from this message
// was rejected within the given window. The window matters: this rule
// exists to stop the re-derivation that happens one debounce later, not to
// ban a message for good.
HasTombstoneForMessage(
ctx context.Context, scope MemoryScope, sourceMessageID string, within time.Duration,
) (bool, error)
// ListTombstones returns the most recent rejections for this subject.
ListTombstones(ctx context.Context, scope MemoryScope, limit int) ([]*types.MemoryTombstone, error)
// HasTombstone reports whether this exact statement was already forgotten.
HasTombstone(ctx context.Context, scope MemoryScope, fingerprint string) (bool, error)
// ExpireOverdue archives items whose expires_at has passed and returns how
// many were archived.
ExpireOverdue(ctx context.Context, scope MemoryScope) (int64, error)
// MarkConsolidated records that this subject's whole store was just
// reviewed, so the next review waits out the interval.
MarkConsolidated(ctx context.Context, scope MemoryScope) error
// MarkForcedConsolidated records that this subject asked for a review
// themselves. Kept apart from MarkConsolidated so the daily pass and the
// button rate limit each other independently.
MarkForcedConsolidated(ctx context.Context, scope MemoryScope) error
// UpsertItemEmbedding stores or replaces the vector for one memory.
UpsertItemEmbedding(ctx context.Context, scope MemoryScope, embedding *types.MemoryItemEmbedding) error
// DeleteItemEmbedding drops one memory's vector. What a memory embeds to
// can change after it is written — an interest embeds the other wordings
// of its subject — and dropping the vector is how the existing backfill is
// asked to rebuild it.
DeleteItemEmbedding(ctx context.Context, scope MemoryScope, itemID string) error
// ItemEmbeddings loads the vectors for the given items, keyed by item id.
// Only vectors produced by modelID are returned: vectors from a different
// model are not comparable, so mixing them would score nonsense.
ItemEmbeddings(ctx context.Context, scope MemoryScope, itemIDs []string, modelID string) (map[string][]float32, error)
// ItemsMissingEmbeddings returns active items that have no vector yet, so a
// background pass can fill them in.
ItemsMissingEmbeddings(ctx context.Context, scope MemoryScope, modelID string, limit int) ([]*types.MemoryItem, error)
// ListLive returns items of one kind that the user can see: in use plus
// proposed and awaiting a decision.
ListLive(ctx context.Context, scope MemoryScope, kind string, limit int) ([]*types.MemoryItem, error)
// ConfirmPendingItem atomically activates a proposal and supersedes its
// conflicting active memories. A missing item returns (nil, nil).
ConfirmPendingItem(ctx context.Context, scope MemoryScope, id string) (*types.MemoryItem, error)
// RejectPendingItem atomically forgets a proposal and records its tombstone.
// Both review methods reject non-pending items with ErrMemoryItemNotPending.
RejectPendingItem(ctx context.Context, scope MemoryScope, id string) (*types.MemoryItem, error)
// BumpTopic records one more sighting of a topic and returns the running
// total, so a caller can decide whether it has recurred enough to promote.
BumpTopic(
ctx context.Context, scope MemoryScope, topic, normalizedKey, alias string,
) (*types.MemoryTopicStat, error)
// RenameTopic gives a subject a better canonical label, keeping the old one
// as an alias. Reports false when another row already holds the new key.
RenameTopic(ctx context.Context, scope MemoryScope, oldKey, newKey, newLabel string) (bool, error)
// MarkTopicPromoted stops a topic from being promoted again.
MarkTopicPromoted(ctx context.Context, scope MemoryScope, normalizedKey string) error
// TopTopics returns the most-asked topics, newest activity first.
TopTopics(ctx context.Context, scope MemoryScope, limit int) ([]*types.MemoryTopicStat, error)
// TopicByKey returns one subject's statistics, or nil when it is not
// tracked. It exists so an interest can be embedded together with the
// other wordings this person has used for the same subject.
TopicByKey(ctx context.Context, scope MemoryScope, normalizedKey string) (*types.MemoryTopicStat, error)
// TopicByID returns one topic row inside the scope, or (nil, nil) when it
// is absent. Used by the memory manager so a user can promote or dismiss
// a subject that has not yet become a memory.
TopicByID(ctx context.Context, scope MemoryScope, id string) (*types.MemoryTopicStat, error)
// ListUnpromotedTopics returns subjects that have been counted but have
// not yet become an interest, closest to the threshold first.
ListUnpromotedTopics(ctx context.Context, scope MemoryScope, limit, offset int) ([]*types.MemoryTopicStat, int64, error)
// DeleteTopic removes one topic row. Counting starts over if the subject
// is asked about again, unless a tombstone blocks promotion.
DeleteTopic(ctx context.Context, scope MemoryScope, id string) error
// DeleteAllTopics drops every topic counter in the scope. Clearing memory
// has to include these, or a subject sitting at N-1 would promote on the
// next question after the user asked to forget everything.
DeleteAllTopics(ctx context.Context, scope MemoryScope) error
// BumpDocAffinity records that an answer for this person drew on a document.
BumpDocAffinity(ctx context.Context, scope MemoryScope, docs []types.MemoryDocAffinity) error
// DocAffinity returns this person's affinity for the given documents.
DocAffinity(ctx context.Context, scope MemoryScope, knowledgeIDs []string) (map[string]int, error)
// TopDocAffinity returns the documents this person relies on most.
TopDocAffinity(ctx context.Context, scope MemoryScope, limit int) ([]*types.MemoryDocAffinity, error)
// DocAffinityByID returns one affinity row inside the scope, or (nil, nil)
// when it is absent.
DocAffinityByID(ctx context.Context, scope MemoryScope, id string) (*types.MemoryDocAffinity, error)
// ListFamiliarDocs returns documents cited often enough to count as a
// habit, most-used first.
ListFamiliarDocs(ctx context.Context, scope MemoryScope, minHits, limit, offset int) ([]*types.MemoryDocAffinity, int64, error)
// DeleteDocAffinity removes one document counter.
DeleteDocAffinity(ctx context.Context, scope MemoryScope, id string) error
// DeleteAllDocAffinity drops every document counter in the scope.
DeleteAllDocAffinity(ctx context.Context, scope MemoryScope) error
// ArchiveLowestRanked archives active items beyond the capacity cap and
// returns how many were archived.
ArchiveLowestRanked(ctx context.Context, scope MemoryScope, keep int) (int64, error)
// CountActive returns the number of active items in the scope.
CountActive(ctx context.Context, scope MemoryScope) (int64, error)
}
// MemoryRecall is what one turn pulls in: the resident block plus any
// situational items matched against the query.
type MemoryRecall struct {
// Prompt is the ready-to-append envelope, empty when nothing was recalled.
Prompt string
// Items are the situational items plus the resident items that produced
// Prompt, in the order they appear. Surfaced to the chat UI.
Items []*types.MemoryItem
}
// MemorySearchResult is what an on-demand lookup into the memory store
// returns.
//
// It is a separate type from MemoryRecall because the two have different
// consumers. Recall produces a prompt envelope for a turn; a search produces
// items for a tool, and that tool has to tell the model *why* it got nothing.
// "This user has memory switched off" and "nothing stored matches" call for
// different answers, and collapsing both into an empty slice would have the
// agent report a blank memory store to someone who simply disabled it.
type MemorySearchResult struct {
// Available is false when memory is off at any level — workspace, user or
// the agent handling this request.
Available bool
// Items are the matches, most relevant first.
Items []*types.MemoryItem
}
// RetrievalContext is what memory contributes to retrieval rather than to the
// answer prompt: who this person is, what they keep asking about, and which
// documents they rely on.
//
// This is the part of memory that earns its keep in a knowledge-base product.
// The same question means different things to different people — "how do I tune
// the segmentation" from someone working on medical imaging should not retrieve
// the same passages as it would from someone working on autonomous driving —
// and that difference has to be applied before retrieval, not after.
type RetrievalContext struct {
// Background is a compact description of the person, for query rewriting.
Background string
// Interests are the topics they keep returning to.
Interests []string
// Documents are titles they rely on, as vocabulary for the rewriter.
Documents []string
// Items are the memories behind the above, so the UI can show what
// influenced retrieval instead of it happening invisibly.
Items []*types.MemoryItem
}
func (c RetrievalContext) Empty() bool {
return c.Background == "" && len(c.Interests) == 0 && len(c.Documents) == 0
}
// MemoryService is the read/write API for long-term memory.
type MemoryService interface {
// Recall assembles the memory to inject for one turn. It performs no LLM
// calls and returns an empty recall (never an error) whenever memory is
// disabled at any level, so callers can use it unconditionally.
Recall(ctx context.Context, query string) MemoryRecall
// SearchMemory ranks the user's stored memories against an arbitrary
// query, for callers that need to reach past what Recall's per-turn budget
// admitted. Like Recall it performs no LLM call and never errors.
SearchMemory(ctx context.Context, query string, limit int) MemorySearchResult
// MemoryAvailable reports whether this request may read memory at all:
// the workspace switch, the user's own opt out and the agent's preference
// combined.
//
// Read paths do not need this — Recall and SearchMemory already degrade on
// their own. It exists for callers that must decide whether to *offer* a
// memory-backed feature, where the difference between "off" and "empty"
// has to be settled before anything is built rather than after it is
// called.
MemoryAvailable(ctx context.Context) bool
// RetrievalContextFor returns what memory contributes to retrieval. Like
// Recall it makes no model call and degrades to an empty value.
RetrievalContextFor(ctx context.Context) RetrievalContext
// DocumentAffinity scores the given documents by how much this person has
// relied on them before. Absent documents simply have no entry.
DocumentAffinity(ctx context.Context, knowledgeIDs []string) map[string]int
// RecordAnswerSources notes which documents an answer drew on, which is the
// only per-person retrieval signal available without asking anything.
RecordAnswerSources(ctx context.Context, refs []types.MemoryDocAffinity)
// ObserveQuestionTopics counts what a person asked about so a recurring
// subject can become an interest. Returns any newly promoted interests.
ObserveQuestionTopics(ctx context.Context, topics []string) []string
// Remember stores a statement the user explicitly asked to keep.
Remember(ctx context.Context, item types.MemoryItem) (*types.MemoryItem, error)
// ScheduleExtraction debounces and enqueues background distillation for a
// finished turn. Best effort: failures are logged, never returned.
ScheduleExtraction(ctx context.Context, sessionID, messageID, chatModelID string)
// Handle runs the background distillation task.
Handle(ctx context.Context, task *asynq.Task) error
// ListItems backs the memory manager list.
ListItems(ctx context.Context, status string, limit, offset int) ([]*types.MemoryItem, int64, error)
// ListTopics returns subjects that have been counted but not yet promoted
// into an interest, so the memory manager can show what is still forming.
ListTopics(ctx context.Context, limit, offset int) ([]*types.MemoryTopicView, int64, error)
// PromoteTopic turns a counted subject into an interest immediately,
// without waiting for the remaining hits.
PromoteTopic(ctx context.Context, id string) (*types.MemoryItem, error)
// DeleteTopic stops tracking a subject. The refusal is remembered so the
// same topic is not promoted automatically later.
DeleteTopic(ctx context.Context, id string) error
// ListDocuments returns documents this person keeps drawing answers from.
ListDocuments(ctx context.Context, limit, offset int) ([]*types.MemoryDocView, int64, error)
// DeleteDocument stops using one document as a personal retrieval signal.
DeleteDocument(ctx context.Context, id string) error
// FamiliarKnowledgeIDs returns the knowledge ids that have been cited
// often enough to count as a habit. Used to highlight Wiki pages whose
// source documents this person actually works from. Empty on any failure
// so callers can use it unconditionally.
FamiliarKnowledgeIDs(ctx context.Context) []string
// CreateItem adds a memory typed by the user in the memory manager.
CreateItem(ctx context.Context, kind, content string, importance int) (*types.MemoryItem, error)
// ConfirmItem accepts a memory the system inferred, so it starts being used.
ConfirmItem(ctx context.Context, id string) (*types.MemoryItem, error)
// RejectItem declines an inference and remembers the refusal, so the same
// guess is not proposed again next week.
RejectItem(ctx context.Context, id string) error
// UpdateItem edits one item's content and importance.
UpdateItem(ctx context.Context, id, content string, importance int) (*types.MemoryItem, error)
// DeleteItem forgets one item.
DeleteItem(ctx context.Context, id string) error
// Clear forgets everything in the caller's memory space.
Clear(ctx context.Context) (int64, error)
// ConsolidateNow reviews the caller's store for near-duplicates and stale
// tasks immediately, without waiting for the daily distillation pass.
ConsolidateNow(ctx context.Context) (*types.MemoryConsolidationResult, error)
// GetSettings returns the effective per-user memory settings.
GetSettings(ctx context.Context) (*types.MemorySettings, error)
// SetEnabled flips the per-user opt out.
SetEnabled(ctx context.Context, enabled bool) error
// RetryExtraction renews only the caller's exhausted automatic retry budget.
RetryExtraction(ctx context.Context) error
}
+131
View File
@@ -0,0 +1,131 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// MessageService defines the message service interface
type MessageService interface {
// CreateMessage creates a message
CreateMessage(ctx context.Context, message *types.Message) (*types.Message, error)
// GetMessage gets a message
GetMessage(ctx context.Context, sessionID string, id string) (*types.Message, error)
// GetMessagesBySession gets all messages of a session
GetMessagesBySession(ctx context.Context, sessionID string, page int, pageSize int) ([]*types.Message, error)
// GetRecentMessagesBySession gets recent messages of a session
GetRecentMessagesBySession(ctx context.Context, sessionID string, limit int) ([]*types.Message, error)
// GetMessagesBySessionBeforeTime gets messages before a specific time of a session
GetMessagesBySessionBeforeTime(
ctx context.Context, sessionID string, beforeTime time.Time, limit int,
) ([]*types.Message, error)
// UpdateMessage updates a message
UpdateMessage(ctx context.Context, message *types.Message) error
// UpdateMessageImages updates only the images JSONB column for a message.
UpdateMessageImages(ctx context.Context, sessionID, messageID string, images types.MessageImages) error
// UpdateMessageRenderedContent updates the rendered_content column for a user message.
UpdateMessageRenderedContent(ctx context.Context, sessionID, messageID string, renderedContent string) error
// DeleteMessage deletes a message
DeleteMessage(ctx context.Context, sessionID string, id string) error
// ClearSessionMessages deletes all messages in a session, along with their chat history KB entries
ClearSessionMessages(ctx context.Context, sessionID string) error
// SearchMessages searches messages by keyword and/or vector similarity across the caller's own sessions.
// Uses the chat history knowledge base for vector search instead of in-memory computation.
SearchMessages(ctx context.Context, params *types.MessageSearchParams) (*types.MessageSearchResult, error)
// IndexMessageToKB indexes a message (Q&A pair) into the chat history knowledge base asynchronously.
// Called after assistant message is created to enable future vector search.
IndexMessageToKB(ctx context.Context, userQuery string, assistantAnswer string, messageID string, sessionID string)
// DeleteMessageKnowledge deletes the Knowledge entry associated with a message from the chat history KB.
DeleteMessageKnowledge(ctx context.Context, knowledgeID string)
// DeleteSessionKnowledge deletes all Knowledge entries for messages in a session from the chat history KB.
DeleteSessionKnowledge(ctx context.Context, sessionID string)
// GetChatHistoryKBStats returns statistics about the chat history knowledge base (indexed message count, etc.)
GetChatHistoryKBStats(ctx context.Context) (*types.ChatHistoryKBStats, error)
// GetSessionArtifacts returns every skill-produced MessageArtifact
// recorded against any assistant message of the session. Used to power
// the frontend "download files generated in this session" drawer and
// to clean up storage blobs on session deletion.
GetSessionArtifacts(ctx context.Context, sessionID string) (types.MessageArtifacts, error)
}
// MessageRepository defines the message repository interface
type MessageRepository interface {
// CreateMessage creates a message
CreateMessage(ctx context.Context, message *types.Message) (*types.Message, error)
// GetMessage gets a message
GetMessage(ctx context.Context, sessionID string, id string) (*types.Message, error)
// GetMessagesBySession gets all messages of a session
GetMessagesBySession(ctx context.Context, sessionID string, page int, pageSize int) ([]*types.Message, error)
// GetRecentMessagesBySession gets recent messages of a session
GetRecentMessagesBySession(ctx context.Context, sessionID string, limit int) ([]*types.Message, error)
// GetMessagesBySessionBeforeTime gets messages before a specific time of a session
GetMessagesBySessionBeforeTime(
ctx context.Context, sessionID string, beforeTime time.Time, limit int,
) ([]*types.Message, error)
// ListMessagesBySessionAfterTime returns messages created strictly after
// afterTime, oldest first. Long-term memory distillation walks forward from
// a watermark, so it needs the oldest unprocessed messages rather than the
// newest ones: paging from the newest end would skip everything in between
// once a session outruns the page size.
ListMessagesBySessionAfterTime(
ctx context.Context, sessionID string, afterTime time.Time, limit int,
) ([]*types.Message, error)
// MemoryOwnedSessionIDs enforces exact tenant/principal ownership. Legacy
// unowned and shared group-thread sessions cannot populate personal memory.
MemoryOwnedSessionIDs(ctx context.Context, scope MemoryScope, sessionIDs []string) (map[string]bool, error)
// MemoryTurnStart returns the user input time for a completed turn, only
// inside that principal's session. A missing or foreign turn is an error.
MemoryTurnStart(ctx context.Context, scope MemoryScope, sessionID, messageID string) (time.Time, error)
// MemoryAllowedMessageIDs rechecks the Agent attached to each completed turn.
MemoryAllowedMessageIDs(ctx context.Context, scope MemoryScope, sessionID string, messages []*types.Message) (map[string]bool, error)
// UpdateMessage updates a message
UpdateMessage(ctx context.Context, message *types.Message) error
// UpdateMessageImages updates only the images JSONB column for a message
UpdateMessageImages(ctx context.Context, sessionID, messageID string, images types.MessageImages) error
// UpdateMessageRenderedContent updates the rendered_content column for a user message
UpdateMessageRenderedContent(ctx context.Context, sessionID, messageID string, renderedContent string) error
// DeleteMessage deletes a message
DeleteMessage(ctx context.Context, sessionID string, id string) error
// DeleteMessagesBySessionID deletes all messages belonging to a session
DeleteMessagesBySessionID(ctx context.Context, sessionID string) error
// GetFirstMessageOfUser gets the first message of a user
GetFirstMessageOfUser(ctx context.Context, sessionID string) (*types.Message, error)
// SearchMessagesByKeyword searches messages by keyword (ILIKE) across sessions for a tenant
// OwnedSessionIDs narrows a set of session ids to the ones this person owns.
OwnedSessionIDs(ctx context.Context, tenantID uint64, ownerID string, sessionIDs []string) (map[string]bool, error)
SearchMessagesByKeyword(ctx context.Context, tenantID uint64, ownerID, keyword string, sessionIDs []string, limit int) ([]*types.MessageWithSession, error)
// GetMessagesByKnowledgeIDs retrieves messages by their associated Knowledge IDs
GetMessagesByKnowledgeIDs(ctx context.Context, knowledgeIDs []string) ([]*types.MessageWithSession, error)
// GetMessagesByRequestIDs retrieves messages by their request IDs (used to fetch Q&A pair partners)
GetMessagesByRequestIDs(ctx context.Context, requestIDs []string) ([]*types.MessageWithSession, error)
// GetKnowledgeIDsBySessionID retrieves all knowledge IDs for messages in a session
GetKnowledgeIDsBySessionID(ctx context.Context, sessionID string) ([]string, error)
// UpdateMessageKnowledgeID updates the knowledge_id field for a message
UpdateMessageKnowledgeID(ctx context.Context, messageID string, knowledgeID string) error
// GetSessionArtifacts returns every skill-produced MessageArtifact recorded
// against any assistant message of the session, in creation order. The
// implementation only projects the artifacts JSONB column, so it stays
// cheap even for long conversations. Empty slice + nil error means the
// session has no artifacts yet (never an error).
GetSessionArtifacts(ctx context.Context, sessionID string) (types.MessageArtifacts, error)
// GetSessionAttachments returns every user-uploaded attachment recorded in
// the session. Implementations should project only the attachments column.
GetSessionAttachments(ctx context.Context, sessionID string) (types.MessageAttachments, error)
}
@@ -0,0 +1,53 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// MessageSuggestionRepository persists generated suggestions and their
// product-analytics events. AcquireGeneration serializes duplicate requests
// from reconnecting clients with an expiring lease.
type MessageSuggestionRepository interface {
GetByCacheKey(
ctx context.Context,
tenantID uint64,
assistantMessageID string,
placement string,
configHash string,
locale string,
) (*types.MessageSuggestionSet, error)
GetByID(ctx context.Context, tenantID uint64, sessionID string, id string) (*types.MessageSuggestionSet, error)
AcquireGeneration(
ctx context.Context,
set *types.MessageSuggestionSet,
regenerate bool,
) (*types.MessageSuggestionSet, bool, error)
Save(ctx context.Context, set *types.MessageSuggestionSet) error
CreateEvent(ctx context.Context, event *types.MessageSuggestionEvent) error
DeleteByMessageID(ctx context.Context, tenantID uint64, sessionID string, messageID string) error
DeleteBySessionID(ctx context.Context, tenantID uint64, sessionID string) error
}
type MessageSuggestionService interface {
EnsureFollowUps(
ctx context.Context,
sessionID string,
assistantMessageID string,
regenerate bool,
) (*types.MessageSuggestionSet, error)
GetFollowUps(
ctx context.Context,
sessionID string,
assistantMessageID string,
) (*types.MessageSuggestionSet, error)
RecordEvent(
ctx context.Context,
sessionID string,
setID string,
questionID string,
eventType string,
) error
ValidateAttribution(ctx context.Context, sessionID string, query string, attribution *types.SuggestionAttribution) error
}
+69
View File
@@ -0,0 +1,69 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/models/asr"
"github.com/Tencent/WeKnora/internal/models/chat"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/models/rerank"
"github.com/Tencent/WeKnora/internal/models/vlm"
"github.com/Tencent/WeKnora/internal/types"
)
// ModelService defines the model service interface
type ModelService interface {
// CreateModel creates a model
CreateModel(ctx context.Context, model *types.Model) error
// GetModelByID gets a model by ID
GetModelByID(ctx context.Context, id string) (*types.Model, error)
// ListModels lists all models
ListModels(ctx context.Context) ([]*types.Model, error)
// UpdateModel updates a model
UpdateModel(ctx context.Context, model *types.Model) error
// DeleteModel deletes a model
DeleteModel(ctx context.Context, id string) error
// UpdateModelCredentials writes one or more credential fields on the
// model's Parameters. Nil pointer means "do not touch this field";
// empty string is treated as no-op (use ClearModelCredential to remove).
// Returns the updated model.
UpdateModelCredentials(ctx context.Context, id string, apiKey, appSecret *string) (*types.Model, error)
// ClearModelCredential removes a single credential field. field must be
// "api_key" or "app_secret". Clearing an already-empty field is a no-op.
ClearModelCredential(ctx context.Context, id, field string) error
// GetEmbeddingModel gets an embedding model
GetEmbeddingModel(ctx context.Context, modelId string) (embedding.Embedder, error)
// GetEmbeddingModelForTenant gets an embedding model for a specific tenant (for cross-tenant sharing)
GetEmbeddingModelForTenant(ctx context.Context, modelId string, tenantID uint64) (embedding.Embedder, error)
// GetRerankModel gets a rerank model
GetRerankModel(ctx context.Context, modelId string) (rerank.Reranker, error)
// GetChatModel gets a chat model
GetChatModel(ctx context.Context, modelId string) (chat.Chat, error)
// GetVLMModel gets a vision language model
GetVLMModel(ctx context.Context, modelId string) (vlm.VLM, error)
// GetASRModel gets an automatic speech recognition model
GetASRModel(ctx context.Context, modelId string) (asr.ASR, error)
}
// ModelRepository defines the model repository interface
type ModelRepository interface {
// Create creates a model
Create(ctx context.Context, model *types.Model) error
// GetByID gets a model by ID
GetByID(ctx context.Context, tenantID uint64, id string) (*types.Model, error)
// List lists all models
List(
ctx context.Context,
tenantID uint64,
modelType types.ModelType,
source types.ModelSource,
) ([]*types.Model, error)
// Update updates a model
Update(ctx context.Context, model *types.Model) error
// Delete deletes a model
Delete(ctx context.Context, tenantID uint64, id string) error
// ClearDefaultByType clears the default flag for all models of a specific type
// optionally excluding a specific model ID.
ClearDefaultByType(ctx context.Context, tenantID uint, modelType types.ModelType, excludeID string) error
}
+221
View File
@@ -0,0 +1,221 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// OrganizationService defines the organization service interface.
//
// Plan 3 of #1303: organization membership is per-tenant, not per-user.
// Adding/removing/updating "members" therefore takes a tenant_id, and
// permission checks key on (org, tenant). RepresentativeUserID is
// informational and not load-bearing.
type OrganizationService interface {
// Organization CRUD
CreateOrganization(ctx context.Context, userID string, tenantID uint64, req *types.CreateOrganizationRequest) (*types.Organization, error)
GetOrganization(ctx context.Context, id string) (*types.Organization, error)
GetOrganizationByInviteCode(ctx context.Context, inviteCode string) (*types.Organization, error)
ListTenantOrganizations(ctx context.Context, tenantID uint64) ([]*types.Organization, error)
UpdateOrganization(ctx context.Context, id string, userID string, tenantID uint64, req *types.UpdateOrganizationRequest) (*types.Organization, error)
DeleteOrganization(ctx context.Context, id string, userID string, tenantID uint64) error
// Member Management (Plan 3: members are tenants)
// AddTenantMember adds a tenant to the org with the given role; representativeUserID
// is only used for display labelling.
AddTenantMember(ctx context.Context, orgID string, tenantID uint64, representativeUserID string, role types.OrgMemberRole) error
RemoveTenantMember(ctx context.Context, orgID string, memberTenantID uint64, operatorUserID string, operatorTenantID uint64) error
UpdateTenantMemberRole(ctx context.Context, orgID string, memberTenantID uint64, role types.OrgMemberRole, operatorUserID string, operatorTenantID uint64) error
ListTenantMembers(ctx context.Context, orgID string) ([]*types.OrganizationTenantMember, error)
GetTenantMember(ctx context.Context, orgID string, tenantID uint64) (*types.OrganizationTenantMember, error)
// Invite Code
GenerateInviteCode(ctx context.Context, orgID string, userID string, tenantID uint64) (string, error)
JoinByInviteCode(ctx context.Context, inviteCode string, userID string, tenantID uint64) (*types.Organization, error)
// Searchable organizations (discovery)
SearchSearchableOrganizations(ctx context.Context, tenantID uint64, query string, limit int) (*types.ListSearchableOrganizationsResponse, error)
JoinByOrganizationID(ctx context.Context, orgID string, userID string, tenantID uint64, message string, requestedRole types.OrgMemberRole) (*types.Organization, error)
// Join Requests (for organizations that require approval)
SubmitJoinRequest(ctx context.Context, orgID string, userID string, tenantID uint64, message string, requestedRole types.OrgMemberRole) (*types.OrganizationJoinRequest, error)
ListJoinRequests(ctx context.Context, orgID string) ([]*types.OrganizationJoinRequest, error)
CountPendingJoinRequests(ctx context.Context, orgID string) (int64, error)
ReviewJoinRequest(ctx context.Context, orgID string, requestID string, approved bool, reviewerID string, reviewerTenantID uint64, message string, assignRole *types.OrgMemberRole) error
// Role Upgrade Requests (for existing tenants to request higher permissions)
RequestRoleUpgrade(ctx context.Context, orgID string, userID string, tenantID uint64, requestedRole types.OrgMemberRole, message string) (*types.OrganizationJoinRequest, error)
GetPendingUpgradeRequest(ctx context.Context, orgID string, tenantID uint64) (*types.OrganizationJoinRequest, error)
// Permission Check (drive purely off the requesting tenant)
IsTenantOrgAdmin(ctx context.Context, orgID string, tenantID uint64) (bool, error)
GetTenantRoleInOrg(ctx context.Context, orgID string, tenantID uint64) (types.OrgMemberRole, error)
}
// OrganizationRepository defines the organization repository interface.
//
// Members are stored per-tenant in `organization_tenant_members`.
type OrganizationRepository interface {
// Organization CRUD
Create(ctx context.Context, org *types.Organization) error
GetByID(ctx context.Context, id string) (*types.Organization, error)
GetByInviteCode(ctx context.Context, inviteCode string) (*types.Organization, error)
ListByTenantID(ctx context.Context, tenantID uint64) ([]*types.Organization, error)
ListSearchable(ctx context.Context, query string, limit int) ([]*types.Organization, error)
Update(ctx context.Context, org *types.Organization) error
Delete(ctx context.Context, id string) error
// Tenant member operations
AddTenantMember(ctx context.Context, member *types.OrganizationTenantMember) error
RemoveTenantMember(ctx context.Context, orgID string, tenantID uint64) error
UpdateTenantMemberRole(ctx context.Context, orgID string, tenantID uint64, role types.OrgMemberRole) error
ListTenantMembers(ctx context.Context, orgID string) ([]*types.OrganizationTenantMember, error)
GetTenantMember(ctx context.Context, orgID string, tenantID uint64) (*types.OrganizationTenantMember, error)
ListTenantMembersByTenantForOrgs(ctx context.Context, tenantID uint64, orgIDs []string) (map[string]*types.OrganizationTenantMember, error)
CountTenantMembers(ctx context.Context, orgID string) (int64, error)
// Invite code
UpdateInviteCode(ctx context.Context, orgID string, inviteCode string, expiresAt *time.Time) error
// Join requests (still keyed on the *requesting* user, but a single
// pending request per (org, tenant) since the approve action grants
// the whole tenant access).
CreateJoinRequest(ctx context.Context, request *types.OrganizationJoinRequest) error
GetJoinRequestByID(ctx context.Context, id string) (*types.OrganizationJoinRequest, error)
GetPendingJoinRequestByTenant(ctx context.Context, orgID string, tenantID uint64) (*types.OrganizationJoinRequest, error)
GetPendingRequestByTenantAndType(ctx context.Context, orgID string, tenantID uint64, requestType types.JoinRequestType) (*types.OrganizationJoinRequest, error)
ListJoinRequests(ctx context.Context, orgID string, status types.JoinRequestStatus) ([]*types.OrganizationJoinRequest, error)
CountJoinRequests(ctx context.Context, orgID string, status types.JoinRequestStatus) (int64, error)
UpdateJoinRequestStatus(ctx context.Context, id string, status types.JoinRequestStatus, reviewedBy string, reviewMessage string) error
}
// KBShareService defines the knowledge base sharing service interface.
//
// Plan 3 of #1303: permission resolution keys on the caller's tenant
// (not user). The 3-dimension cap is applied inside CheckTenantKBPermission
// and is the canonical permission gate for shared KBs:
//
// effective = min(share.Permission, tenant_org_role, tenant_role_cap)
//
// where tenant_role_cap pins tenant Viewers to OrgRoleViewer regardless
// of the org-level grant — Viewer in your own tenant must always be
// read-only on shared resources.
type KBShareService interface {
// Share Management
ShareKnowledgeBase(ctx context.Context, kbID string, orgID string, userID string, tenantID uint64, permission types.OrgMemberRole) (*types.KnowledgeBaseShare, error)
UpdateSharePermission(ctx context.Context, shareID string, permission types.OrgMemberRole, userID string, tenantID uint64) error
RemoveShare(ctx context.Context, shareID string, userID string, tenantID uint64) error
// Query
// ListSharesByKnowledgeBase lists shares for a KB; tenantID must own the KB (authz check).
ListSharesByKnowledgeBase(ctx context.Context, kbID string, tenantID uint64) ([]*types.KnowledgeBaseShare, error)
ListSharesByOrganization(ctx context.Context, orgID string) ([]*types.KnowledgeBaseShare, error)
ListSharedKnowledgeBases(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.SharedKnowledgeBaseInfo, error)
ListSharedKnowledgeBasesInOrganization(ctx context.Context, orgID string, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.OrganizationSharedKnowledgeBaseItem, error)
// ListSharedKnowledgeBaseIDsByOrganizations returns per-org direct shared KB IDs (batch, for sidebar count).
ListSharedKnowledgeBaseIDsByOrganizations(ctx context.Context, orgIDs []string, tenantID uint64) (map[string][]string, error)
GetShare(ctx context.Context, shareID string) (*types.KnowledgeBaseShare, error)
GetShareByKBAndOrg(ctx context.Context, kbID string, orgID string) (*types.KnowledgeBaseShare, error)
// Permission Check (Plan 3: tenant-keyed). Returns the effective
// OrgMemberRole the caller can use against the shared KB after the
// 3-D cap, plus a bool indicating whether *any* share matches the
// caller's tenant. callerTenantRole is the caller's tenant-RBAC
// role and is used to apply the Viewer cap.
CheckTenantKBPermission(ctx context.Context, kbID string, callerTenantID uint64, callerTenantRole types.TenantRole) (types.OrgMemberRole, bool, error)
// HasTenantKBPermission is a thin "do I have at least N" wrapper over
// CheckTenantKBPermission.
HasTenantKBPermission(ctx context.Context, kbID string, callerTenantID uint64, callerTenantRole types.TenantRole, requiredRole types.OrgMemberRole) (bool, error)
// Get source tenant for cross-tenant embedding
GetKBSourceTenant(ctx context.Context, kbID string) (uint64, error)
// Count shares for knowledge bases
CountSharesByKnowledgeBaseIDs(ctx context.Context, kbIDs []string) (map[string]int64, error)
// CountByOrganizations returns share counts per organization (for sidebar); excludes deleted KBs
CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error)
}
// KBShareRepository defines the knowledge base sharing repository interface
type KBShareRepository interface {
// CRUD
Create(ctx context.Context, share *types.KnowledgeBaseShare) error
GetByID(ctx context.Context, id string) (*types.KnowledgeBaseShare, error)
GetByKBAndOrg(ctx context.Context, kbID string, orgID string) (*types.KnowledgeBaseShare, error)
Update(ctx context.Context, share *types.KnowledgeBaseShare) error
Delete(ctx context.Context, id string) error
// DeleteByKnowledgeBaseID soft-deletes all shares for a knowledge base (e.g. when KB is deleted)
DeleteByKnowledgeBaseID(ctx context.Context, kbID string) error
// DeleteByOrganizationID soft-deletes all shares for an organization (e.g. when the org is deleted)
DeleteByOrganizationID(ctx context.Context, orgID string) error
// List
ListByKnowledgeBase(ctx context.Context, kbID string) ([]*types.KnowledgeBaseShare, error)
ListByOrganization(ctx context.Context, orgID string) ([]*types.KnowledgeBaseShare, error)
ListByOrganizations(ctx context.Context, orgIDs []string) ([]*types.KnowledgeBaseShare, error)
CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error)
// Query for tenant's accessible shared knowledge bases
ListSharedKBsForTenant(ctx context.Context, tenantID uint64) ([]*types.KnowledgeBaseShare, error)
// Count shares
CountSharesByKnowledgeBaseID(ctx context.Context, kbID string) (int64, error)
CountSharesByKnowledgeBaseIDs(ctx context.Context, kbIDs []string) (map[string]int64, error)
}
// AgentShareService defines the agent sharing service interface.
//
// Plan 3 of #1303: visibility and access checks key on the caller's
// tenant. callerTenantRole flows through so the 3-D cap (tenant Viewer
// → at most OrgRoleViewer) can be applied consistently.
type AgentShareService interface {
ShareAgent(ctx context.Context, agentID string, orgID string, userID string, tenantID uint64, permission types.OrgMemberRole) (*types.AgentShare, error)
RemoveShare(ctx context.Context, shareID string, userID string, tenantID uint64) error
ListSharesByAgent(ctx context.Context, agentID string, tenantID uint64) ([]*types.AgentShare, error)
ListSharesByOrganization(ctx context.Context, orgID string) ([]*types.AgentShare, error)
ListSharedAgents(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.SharedAgentInfo, error)
ListSharedAgentsInOrganization(ctx context.Context, orgID string, tenantID uint64, callerTenantRole types.TenantRole) ([]*types.OrganizationSharedAgentItem, error)
// ListSharedAgentsInOrganizations returns per-org agent list (batch, for sidebar count merge).
ListSharedAgentsInOrganizations(ctx context.Context, orgIDs []string, tenantID uint64, callerTenantRole types.TenantRole) (map[string][]*types.OrganizationSharedAgentItem, error)
// SetSharedAgentDisabledByMe sets whether the current tenant has "disabled" this shared agent for their conversation dropdown (per-tenant preference; will be revisited in a follow-up PR).
SetSharedAgentDisabledByMe(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64, disabled bool) error
// GetSharedAgentForTenant returns the shared agent by agentID if the caller's tenant has access; used to resolve KB scope for @ mention.
GetSharedAgentForTenant(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, agentID string, sourceTenantID ...uint64) (*types.CustomAgent, error)
// TenantCanAccessKBViaSomeSharedAgent returns true if the caller's tenant has at least one shared agent that can access the given KB (for opening KB detail from "通过智能体可见" list without passing agent_id).
TenantCanAccessKBViaSomeSharedAgent(ctx context.Context, tenantID uint64, callerTenantRole types.TenantRole, kb *types.KnowledgeBase) (bool, error)
GetShare(ctx context.Context, shareID string) (*types.AgentShare, error)
GetShareByAgentAndOrg(ctx context.Context, agentID string, orgID string) (*types.AgentShare, error)
// GetShareByAgentIDForTenant returns one share for the given agentID that the tenant can access, excluding source_tenant_id == excludeTenantID (e.g. caller's own tenant to get shared-from-other only).
GetShareByAgentIDForTenant(ctx context.Context, tenantID uint64, agentID string, excludeTenantID uint64) (*types.AgentShare, error)
// CountByOrganizations returns share counts per organization (for sidebar); excludes deleted agents
CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error)
}
// AgentShareRepository defines the agent sharing repository interface
type AgentShareRepository interface {
Create(ctx context.Context, share *types.AgentShare) error
GetByID(ctx context.Context, id string) (*types.AgentShare, error)
GetByAgentAndOrg(ctx context.Context, agentID string, orgID string) (*types.AgentShare, error)
Update(ctx context.Context, share *types.AgentShare) error
Delete(ctx context.Context, id string) error
DeleteByAgentIDAndSourceTenant(ctx context.Context, agentID string, sourceTenantID uint64) error
DeleteByOrganizationID(ctx context.Context, orgID string) error
ListByAgent(ctx context.Context, agentID string) ([]*types.AgentShare, error)
ListByOrganization(ctx context.Context, orgID string) ([]*types.AgentShare, error)
ListByOrganizations(ctx context.Context, orgIDs []string) ([]*types.AgentShare, error)
ListSharedAgentsForTenant(ctx context.Context, tenantID uint64) ([]*types.AgentShare, error)
GetShareByAgentIDAndSourceForTenant(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64) (*types.AgentShare, error)
CountByOrganizations(ctx context.Context, orgIDs []string) (map[string]int64, error)
// GetShareByAgentIDForTenant returns one share for the given agentID that the tenant can access (tenant in org), excluding source_tenant_id == excludeTenantID.
GetShareByAgentIDForTenant(ctx context.Context, tenantID uint64, agentID string, excludeTenantID uint64) (*types.AgentShare, error)
}
// TenantDisabledSharedAgentRepository stores per-tenant "disabled" agents (hidden from conversation dropdown; own and shared)
type TenantDisabledSharedAgentRepository interface {
ListByTenantID(ctx context.Context, tenantID uint64) ([]*types.TenantDisabledSharedAgent, error)
// ListDisabledOwnAgentIDs returns agent IDs that this tenant has disabled for their own agents (source_tenant_id = tenant_id)
ListDisabledOwnAgentIDs(ctx context.Context, tenantID uint64) ([]string, error)
Add(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64) error
Remove(ctx context.Context, tenantID uint64, agentID string, sourceTenantID uint64) error
}
+78
View File
@@ -0,0 +1,78 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// ResourceCleaner owns process-lifetime cleanup callbacks (database clients,
// worker pools, etc.). It is unrelated to persisted StoredResource records.
type ResourceCleaner interface {
Register(cleanup types.CleanupFunc)
RegisterWithName(name string, cleanup types.CleanupFunc)
Cleanup(ctx context.Context) []error
}
// ResourceRepository persists stable resource identities, ownership bindings,
// and short-lived access grants.
type ResourceRepository interface {
Create(ctx context.Context, resource *types.StoredResource) error
GetByID(ctx context.Context, id string) (*types.StoredResource, error)
GetByHandle(ctx context.Context, handle string) (*types.StoredResource, error)
GetByTenantLocation(ctx context.Context, tenantID uint64, locationHash string) (*types.StoredResource, error)
MarkDeleted(ctx context.Context, id string) error
CreateBinding(ctx context.Context, binding *types.ResourceBinding) error
DeleteBinding(ctx context.Context, resourceID, ownerType, ownerID string) error
CountBindings(ctx context.Context, resourceID string) (int64, error)
CreateGrant(ctx context.Context, grant *types.ResourceAccessGrant) error
GetValidGrant(ctx context.Context, tokenHash string, now time.Time) (*types.ResourceAccessGrant, error)
DeleteExpiredGrants(ctx context.Context, before time.Time) error
}
// ResourceRegistration describes one physical object at registration time.
type ResourceRegistration struct {
Kind string
MimeType string
OriginalName string
Size int64
ContentHash string
Temporary bool
}
// ResourceFileScope identifies the domain object authorizing a file proxy.
// TenantID is the file owner's tenant; the caller remains in ctx.
type ResourceFileScope struct {
TenantID uint64
KnowledgeBaseID string
MessageID string
SessionID string
EmbedChannelID string
}
type ResourceFileAuthorizer interface {
AuthorizeFileRead(ctx context.Context, value string, scope ResourceFileScope) error
}
// ResourceCatalog maps public resource references to internal storage
// locations and manages their access capabilities.
type ResourceCatalog interface {
Register(ctx context.Context, tenantID uint64, physicalPath string, meta ResourceRegistration) (string, error)
Resolve(ctx context.Context, reference string) (*types.StoredResource, error)
ResolvePath(ctx context.Context, value string) (string, *types.StoredResource, error)
Bind(ctx context.Context, reference, ownerType, ownerID, relation string) error
// Release drops one owner's claim on a resource and reports how many
// claims remain. Callers use the count to decide whether the bytes may be
// deleted: a resource still referenced by another owner — the assistant
// message an answer was saved from, a second knowledge entry — must
// survive the owner being removed.
//
// remaining is -1 when the answer is unknown (the reference is not a
// catalog handle, or the count could not be read), which callers should
// treat as "delete as before" rather than as "keep forever".
Release(ctx context.Context, reference, ownerType, ownerID string) (remaining int64, err error)
MarkDeleted(ctx context.Context, reference string) error
CreateAccessGrant(ctx context.Context, reference string, ttl time.Duration) (string, error)
ResolveAccessGrant(ctx context.Context, token string) (*types.StoredResource, error)
}
+156
View File
@@ -0,0 +1,156 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/types"
)
// RetrieveEngine defines the retrieve engine interface
type RetrieveEngine interface {
// EngineType gets the retrieve engine type
EngineType() types.RetrieverEngineType
// Retrieve executes the retrieve
Retrieve(ctx context.Context, params types.RetrieveParams) ([]*types.RetrieveResult, error)
// Support gets the supported retrieve types
Support() []types.RetrieverType
}
// RetrieveEngineRepository defines the retrieve engine repository interface
type RetrieveEngineRepository interface {
// Save saves the index info
Save(ctx context.Context, indexInfo *types.IndexInfo, params map[string]any) error
// BatchSave saves the index info list
BatchSave(ctx context.Context, indexInfoList []*types.IndexInfo, params map[string]any) error
// EstimateStorageSize estimates the storage size
EstimateStorageSize(ctx context.Context, indexInfoList []*types.IndexInfo, params map[string]any) int64
// DeleteByChunkIDList deletes the index info by chunk id list
DeleteByChunkIDList(ctx context.Context, indexIDList []string, dimension int, knowledgeType string) error
// DeleteBySourceIDList deletes the index info by source id list
DeleteBySourceIDList(ctx context.Context, sourceIDList []string, dimension int, knowledgeType string) error
// 复制索引数据
// sourceKnowledgeBaseID: 源知识库ID
// sourceToTargetChunkIDMap: 源分块ID到目标分块ID的映射关系
// targetKnowledgeBaseID: 目标知识库ID
// params: 额外参数,如向量表示等
CopyIndices(
ctx context.Context,
sourceKnowledgeBaseID string,
sourceToTargetKBIDMap map[string]string,
sourceToTargetChunkIDMap map[string]string,
targetKnowledgeBaseID string,
dimension int,
knowledgeType string,
) error
// DeleteByKnowledgeIDList deletes the index info by knowledge id list
DeleteByKnowledgeIDList(ctx context.Context, knowledgeIDList []string, dimension int, knowledgeType string) error
// BatchUpdateChunkEnabledStatus updates the enabled status of chunks in batch
// chunkStatusMap: map of chunk ID to enabled status (true = enabled, false = disabled)
BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool) error
// BatchUpdateChunkTagID updates the tag ID of chunks in batch
// chunkTagMap: map of chunk ID to tag ID (empty string means no tag)
BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string) error
// RetrieveEngine retrieves the engine
RetrieveEngine
}
// RetrieveEngineRegistry defines the retrieve engine registry interface
type RetrieveEngineRegistry interface {
// Register registers the retrieve engine service
Register(indexService RetrieveEngineService) error
// GetRetrieveEngineService gets the retrieve engine service
GetRetrieveEngineService(engineType types.RetrieverEngineType) (RetrieveEngineService, error)
// GetAllRetrieveEngineServices gets all retrieve engine services
GetAllRetrieveEngineServices() []RetrieveEngineService
// GetByStoreID returns the engine service registered for a specific DB store ID.
//
// IMPORTANT: This method does NOT verify tenant ownership of the returned
// store. Callers MUST use the CreateRetrieveEngineForKB /
// CreateRetrieveEngineFromPayload factory functions in the retriever package
// rather than calling this directly. The factories wrap GetByStoreID with
// tenant ownership verification (defense-in-depth against cross-tenant IDOR).
GetByStoreID(storeID string) (RetrieveEngineService, error)
// GetOrLoadByStoreID returns the engine for storeID, rebuilding it from the
// database when this process has no entry for it. The registry is per-process:
// an engine registered on one instance is missing on every other until that
// instance restarts, and an engine whose creation failed during startup stays
// missing even across restarts. Rebuilding on demand lets both cases recover
// without an operator-driven rollout.
//
// Unlike GetByStoreID, this method scopes its database lookup to tenantID, so
// it cannot hydrate a store belonging to another tenant. Callers should still
// verify ownership first: the tenant scope is defense-in-depth, not a
// replacement for the ownership check.
GetOrLoadByStoreID(
ctx context.Context, tenantID uint64, storeID string,
) (RetrieveEngineService, error)
}
// RetrieveEngineService defines the retrieve engine service interface
type RetrieveEngineService interface {
// Index indexes the index info
Index(ctx context.Context,
embedder embedding.Embedder,
indexInfo *types.IndexInfo,
retrieverTypes []types.RetrieverType,
) error
// BatchIndex indexes the index info list
BatchIndex(ctx context.Context,
embedder embedding.Embedder,
indexInfoList []*types.IndexInfo,
retrieverTypes []types.RetrieverType,
) error
// EstimateStorageSize estimates the storage size
EstimateStorageSize(ctx context.Context,
embedder embedding.Embedder,
indexInfoList []*types.IndexInfo,
retrieverTypes []types.RetrieverType,
) int64
// CopyIndices 从源知识库复制索引到目标知识库,免去重新计算嵌入向量的开销
// sourceKnowledgeBaseID: 源知识库ID
// sourceToTargetChunkIDMap: 源分块ID到目标分块ID的映射关系,key为源分块ID,value为目标分块ID
// targetKnowledgeBaseID: 目标知识库ID
CopyIndices(
ctx context.Context,
sourceKnowledgeBaseID string,
sourceToTargetKBIDMap map[string]string,
sourceToTargetChunkIDMap map[string]string,
targetKnowledgeBaseID string,
dimension int,
knowledgeType string,
) error
// DeleteByChunkIDList deletes the index info by chunk id list
DeleteByChunkIDList(ctx context.Context, indexIDList []string, dimension int, knowledgeType string) error
// DeleteBySourceIDList deletes the index info by source id list
DeleteBySourceIDList(ctx context.Context, sourceIDList []string, dimension int, knowledgeType string) error
// DeleteByKnowledgeIDList deletes the index info by knowledge id list
DeleteByKnowledgeIDList(ctx context.Context, knowledgeIDList []string, dimension int, knowledgeType string) error
// BatchUpdateChunkEnabledStatus updates the enabled status of chunks in batch
// chunkStatusMap: map of chunk ID to enabled status (true = enabled, false = disabled)
BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool) error
// BatchUpdateChunkTagID updates the tag ID of chunks in batch
// chunkTagMap: map of chunk ID to tag ID (empty string means no tag)
BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string) error
// RetrieveEngine retrieves the engine
RetrieveEngine
}
@@ -0,0 +1,17 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// RetrieveGraphRepository is a repository for retrieving graphs
type RetrieveGraphRepository interface {
// AddGraph adds a graph to the repository
AddGraph(ctx context.Context, namespace types.NameSpace, graphs []*types.GraphData) error
// DelGraph deletes a graph from the repository
DelGraph(ctx context.Context, namespace []types.NameSpace) error
// SearchNode searches for nodes in the repository
SearchNode(ctx context.Context, namespace types.NameSpace, nodes []string) (*types.GraphData, error)
}
+109
View File
@@ -0,0 +1,109 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/types"
)
// SessionService defines the session service interface
type SessionService interface {
// CreateSession creates a session
CreateSession(ctx context.Context, session *types.Session) (*types.Session, error)
// GetSession gets a session, honoring the caller's per-user scope with an
// Admin+ read fallback for tenant channel sessions (API / IM / embed).
// Use only for read paths.
GetSession(ctx context.Context, id string) (*types.Session, error)
// GetOwnedSession gets a session strictly within the caller's owner scope
// (no Admin+ API-key fallback). Write/mutation endpoints must use this so a
// tenant admin cannot alter API-key sessions they can only read.
GetOwnedSession(ctx context.Context, id string) (*types.Session, error)
// GetSessionByID loads a session by tenant and id without user scoping.
GetSessionByID(ctx context.Context, tenantID uint64, id string) (*types.Session, error)
// SetSessionOwnerID assigns sessions.user_id for the given session row.
SetSessionOwnerID(ctx context.Context, tenantID uint64, sessionID, ownerID string) error
// GetSessionsByTenant gets all sessions of a tenant
GetSessionsByTenant(ctx context.Context) ([]*types.Session, error)
// GetPagedSessionsByTenant gets paged sessions of a tenant
GetPagedSessionsByTenant(ctx context.Context, page *types.Pagination) (*types.PageResult, error)
// UpdateSession updates a session
UpdateSession(ctx context.Context, session *types.Session) error
// UpdateSessionLastRequestState records the input-bar state used for the
// most recent QA request on this session. Best-effort: callers should log
// but not surface failures to the user.
UpdateSessionLastRequestState(ctx context.Context, sessionID string, state *types.SessionLastRequestState) error
// DeleteSession deletes a session
DeleteSession(ctx context.Context, id string) error
// BatchDeleteSessions deletes multiple sessions by IDs
BatchDeleteSessions(ctx context.Context, ids []string) error
// DeleteAllSessions deletes all sessions for the current tenant
DeleteAllSessions(ctx context.Context) error
// ListSessions returns a page of sessions for the current tenant/user with
// search/source filters and pin-aware ordering. User scope is pulled from ctx.
ListSessions(ctx context.Context, query *types.SessionListQuery) (*types.PageResult, error)
// CountSessionsBySource returns the total for a source filter without the
// Admin+ gate applied by ListSessions (for aggregate stats endpoints).
CountSessionsBySource(ctx context.Context, query *types.SessionListQuery) (int64, error)
// SetSessionPinned pins or unpins the session for the current user scope.
// Returns the number of rows affected; 0 signals "not found" to the handler.
SetSessionPinned(ctx context.Context, sessionID string, pinned bool) (int64, error)
// GenerateTitle generates a title for the current conversation
// modelID: optional model ID to use for title generation (if empty, uses first available KnowledgeQA model)
GenerateTitle(ctx context.Context, session *types.Session, messages []types.Message, modelID string) (string, error)
// GenerateTitleAsync generates a title for the session asynchronously
// It emits an event when the title is generated
// modelID: optional model ID to use for title generation (if empty, uses first available KnowledgeQA model)
GenerateTitleAsync(ctx context.Context, session *types.Session, userQuery string, modelID string, eventBus *event.EventBus)
// KnowledgeQA performs knowledge-based question answering.
// Events are emitted through eventBus (references, answer chunks, completion).
KnowledgeQA(ctx context.Context, req *types.QARequest, eventBus *event.EventBus) error
// KnowledgeQAByEvent performs knowledge-based question answering by event
KnowledgeQAByEvent(ctx context.Context, chatManage *types.ChatManage, eventList []types.EventType) error
// SearchKnowledge performs knowledge-based search, without summarization
// knowledgeBaseIDs: list of knowledge base IDs to search (supports multi-KB)
// knowledgeIDs: list of specific knowledge (file) IDs to search
SearchKnowledge(ctx context.Context, knowledgeBaseIDs []string, knowledgeIDs []string, tagScopes []types.TagScope, query string) ([]*types.SearchResult, error)
// AgentQA performs agent-based question answering with conversation history and streaming support.
AgentQA(ctx context.Context, req *types.QARequest, eventBus *event.EventBus) error
}
// SessionRepository defines the session repository interface
type SessionRepository interface {
// Create creates a session
Create(ctx context.Context, session *types.Session) (*types.Session, error)
// Get gets a session visible to the tenant/user scope.
Get(ctx context.Context, tenantID uint64, userID string, id string) (*types.Session, error)
// GetByID loads a session by tenant and id without user scoping. Callers
// must enforce access (e.g. embed channel + session signature).
GetByID(ctx context.Context, tenantID uint64, id string) (*types.Session, error)
// GetIMPlatform returns the IM platform bound to a session via
// im_channel_sessions, or "" when the session has no IM mapping. Used to
// classify a session's origin folder on read without a full list query.
GetIMPlatform(ctx context.Context, tenantID uint64, sessionID string) (string, error)
// GetByTenantID gets all sessions visible to the tenant/user scope.
GetByTenantID(ctx context.Context, tenantID uint64, userID string) ([]*types.Session, error)
// GetPagedByTenantID gets paged sessions visible to the tenant/user scope.
GetPagedByTenantID(ctx context.Context, tenantID uint64, userID string, page *types.Pagination) ([]*types.Session, int64, error)
// QueryPaged lists sessions with filters, user-scoped ownership and pin-aware ordering.
QueryPaged(ctx context.Context, q *types.SessionListQuery) ([]*types.SessionListItem, int64, error)
// Update updates a session visible to the tenant/user scope.
Update(ctx context.Context, session *types.Session, userID string) (int64, error)
// SetOwnerID assigns sessions.user_id for a tenant-scoped row.
SetOwnerID(ctx context.Context, tenantID uint64, id, ownerID string) (int64, error)
// UpdateLastRequestState persists the most recent input-bar state for a
// session (agent, model, KB scope, etc.) so the chat UI can restore it
// when the session is reopened. Scope rules match Update.
UpdateLastRequestState(ctx context.Context, tenantID uint64, userID string, sessionID string, state *types.SessionLastRequestState) (int64, error)
// SetPinned pins or unpins a session row scoped by tenant.
// userID, when non-empty, is enforced so users cannot pin sessions they don't own.
// Returns the number of rows affected; 0 means the session doesn't exist or is
// not visible to this caller.
SetPinned(ctx context.Context, tenantID uint64, userID string, id string, pinned bool) (int64, error)
// Delete deletes a session visible to the tenant/user scope.
Delete(ctx context.Context, tenantID uint64, userID string, id string) (int64, error)
// BatchDelete deletes multiple sessions visible to the tenant/user scope.
BatchDelete(ctx context.Context, tenantID uint64, userID string, ids []string) (int64, error)
// DeleteAllByTenantID deletes all sessions visible to the tenant/user scope.
DeleteAllByTenantID(ctx context.Context, tenantID uint64, userID string) (int64, error)
}
+16
View File
@@ -0,0 +1,16 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/agent/skills"
)
// SkillService defines the interface for skill business logic
type SkillService interface {
// ListPreloadedSkills returns metadata for all preloaded skills
ListPreloadedSkills(ctx context.Context) ([]*skills.SkillMetadata, error)
// GetSkillByName retrieves a skill by its name
GetSkillByName(ctx context.Context, name string) (*skills.Skill, error)
}
@@ -0,0 +1,31 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
type StorageBackendRepository interface {
Create(ctx context.Context, backend *types.StorageBackend) error
GetByID(ctx context.Context, tenantID uint64, id string) (*types.StorageBackend, error)
List(ctx context.Context, tenantID uint64) ([]*types.StorageBackend, error)
Update(ctx context.Context, backend *types.StorageBackend) error
Delete(ctx context.Context, tenantID uint64, id string) error
FindLegacyAlias(ctx context.Context, tenantID uint64, provider string) (*types.StorageBackend, error)
}
type StorageBackendService interface {
Create(ctx context.Context, backend *types.StorageBackend) error
Update(ctx context.Context, backend *types.StorageBackend) error
Delete(ctx context.Context, tenantID uint64, id string) error
SetDefault(ctx context.Context, tenantID uint64, id string) error
Test(ctx context.Context, backend *types.StorageBackend) error
}
// StorageBackendResolver is the single runtime entry point for resolving one
// concrete storage instance. backendID wins; provider is a legacy fallback.
type StorageBackendResolver interface {
ResolveFileService(ctx context.Context, tenant *types.Tenant, backendID, provider, localBaseDir string) (FileService, string, error)
ResolveBackend(ctx context.Context, tenant *types.Tenant, backendID, provider string) (*types.StorageBackend, error)
}
@@ -0,0 +1,33 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// StreamEvent represents a single event in the stream
type StreamEvent struct {
ID string `json:"id"` // Unique event ID
Type types.ResponseType `json:"type"` // Event type (thinking, tool_call, complete, etc.)
Content string `json:"content"` // Event content (chunk for streaming events)
Done bool `json:"done"` // Whether this event is done
Timestamp time.Time `json:"timestamp"` // When this event occurred
Data map[string]interface{} `json:"data,omitempty"` // Additional event data (references, metadata, etc.)
Usage *types.TokenUsage `json:"usage,omitempty"` // LLM token usage aggregated over the turn (complete events)
}
// StreamManager stream manager interface - minimal append-only design
// All stream state is managed through events: metadata, references, completion, etc.
type StreamManager interface {
// AppendEvent appends a single event to the stream
// Uses Redis RPush for O(1) append performance
// All event types (thinking, tool_call, references, complete) use this method
AppendEvent(ctx context.Context, sessionID, messageID string, event StreamEvent) error
// GetEvents gets events starting from offset
// Uses Redis LRange for incremental reads
// Returns: events slice, next offset for subsequent reads, error
GetEvents(ctx context.Context, sessionID, messageID string, fromOffset int) ([]StreamEvent, int, error)
}
@@ -0,0 +1,94 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// SystemSettingRepository is the storage layer for the platform-wide
// system_settings table. All methods are system-scoped — there is no
// tenant_id; rows are global to the deployment.
type SystemSettingRepository interface {
// Get fetches a row by key. Returns (nil, nil) when the key is not
// present — callers fall back to ENV / default at the service layer.
Get(ctx context.Context, key string) (*types.SystemSetting, error)
// List returns every row. Used by the management UI to render the
// settings page; no pagination yet (the registry is small — single
// digits in P1, expected to stay double-digits long-term).
List(ctx context.Context) ([]*types.SystemSetting, error)
// Upsert writes a row keyed by Key. Insert if missing, update if
// present. Used by SystemSettingService.Update on every save.
Upsert(ctx context.Context, s *types.SystemSetting) error
// Delete removes the row by key. Returns (true, nil) if a row was
// deleted, (false, nil) if no row matched (idempotent — service
// layer treats this as a no-op for audit purposes). Real DB errors
// surface as (_, err).
Delete(ctx context.Context, key string) (bool, error)
}
// SystemSettingService exposes both the 3-tier resolver (used by
// production code paths that consume settings) and the management CRUD
// (used by the SystemAdmin UI).
//
// 3-tier resolver priority: DB > ENV > built-in default. The service
// owns the registry of legal keys; reading or writing an unknown key
// returns an error from Update / falls through to default for Get.
//
// P1 ships with no in-memory cache: every GetXxx hits the DB. The
// callers (file-upload size check, etc.) are not on a hot path so the
// extra ~1ms is negligible. P2 may add Redis pubsub + a TTL cache;
// the SubscribeRedis hook below is a placeholder for that.
type SystemSettingService interface {
// GetInt returns the resolved int64 value for `key`.
//
// envName is the legacy environment-variable name to consult when
// the DB row is absent ("" means the key has no ENV fallback).
// def is the built-in default used when both DB and ENV miss.
//
// Errors at the DB layer degrade gracefully: the function logs a
// warning and falls through to ENV / default rather than returning
// an error to upstream business code (which would have to bubble
// it through every caller — we'd rather mis-serve a request with
// the default than 500 a file upload).
GetInt(ctx context.Context, key string, envName string, def int64) int64
GetString(ctx context.Context, key string, envName string, def string) string
GetBool(ctx context.Context, key string, envName string, def bool) bool
// GetStringList resolves a comma-separated list of strings. envName
// is treated as a comma-separated string at the ENV level (mirrors
// the legacy SSRF_WHITELIST format). The slice returned is always
// non-nil so callers can iterate without a nil check.
GetStringList(ctx context.Context, key string, envName string, def []string) []string
// List, Get, Update are the management-CRUD surface called by the
// SystemAdmin handlers (gated to user.is_system_admin = true at the
// router layer). Update emits an audit log on success.
List(ctx context.Context) ([]*types.SystemSetting, error)
Get(ctx context.Context, key string) (*types.SystemSetting, error)
// Update writes a new value for `key`, validating that:
// 1. key is in the in-code registry (rejects 400 otherwise — UI
// cannot inject arbitrary keys),
// 2. rawValue's Go type matches the registry's expected type
// (e.g. int64 / float64 for "int", string for "string", bool
// for "bool"),
// 3. the actor is captured from ctx (UserIDFromContext) and
// written to last_modified_by + the audit log.
// Returns the persisted row on success.
Update(ctx context.Context, key string, rawValue any) (*types.SystemSetting, error)
// Reset removes the DB override for `key` so the resolver falls
// back to ENV / built-in default. Idempotent: deleting a key that
// was never persisted returns nil. Emits an audit row on actual
// deletions, invalidates the local cache, and publishes to peers.
// Returns an error only when the DB write itself fails; callers
// must NOT translate "no row" into a 404 — that's the success
// path for an idempotent reset.
Reset(ctx context.Context, key string) error
// SubscribeRedis is a P2 hook: when implemented, it will subscribe
// to a "weknora:system_settings:changed" channel and invalidate any
// in-memory cache on receipt. P1 implementations may return a no-op
// because there is no cache yet. Keeping the method in the interface
// now means P2 can drop in a real implementation without changing
// the container wiring.
SubscribeRedis(ctx context.Context) error
}
+65
View File
@@ -0,0 +1,65 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
// KnowledgeTagService defines operations on knowledge base scoped tags.
type KnowledgeTagService interface {
// ListTags lists all tags under a knowledge base with associated statistics.
ListTags(ctx context.Context, kbID string, page *types.Pagination, keyword string) (*types.PageResult, error)
// CreateTag creates a new tag under a knowledge base.
CreateTag(ctx context.Context, kbID string, name string, color string, sortOrder int) (*types.KnowledgeTag, error)
// UpdateTag updates tag basic information.
UpdateTag(ctx context.Context, id string, name *string, color *string, sortOrder *int) (*types.KnowledgeTag, error)
// DeleteTag deletes a tag.
// When contentOnly=true, only deletes the content under the tag but keeps the tag itself.
// excludeIDs: IDs of chunks to exclude from deletion (only valid when deleting chunks)
DeleteTag(ctx context.Context, id string, force bool, contentOnly bool, excludeIDs []string) error
// FindOrCreateTagByName finds a tag by name or creates it if not exists.
FindOrCreateTagByName(ctx context.Context, kbID string, name string) (*types.KnowledgeTag, error)
// ProcessIndexDelete handles async index deletion task
ProcessIndexDelete(ctx context.Context, t *asynq.Task) error
}
// KnowledgeTagRepository defines persistence operations for tags.
type KnowledgeTagRepository interface {
Create(ctx context.Context, tag *types.KnowledgeTag) error
Update(ctx context.Context, tag *types.KnowledgeTag) error
GetByID(ctx context.Context, tenantID uint64, id string) (*types.KnowledgeTag, error)
// GetBySeqID retrieves a tag by its seq_id.
GetBySeqID(ctx context.Context, tenantID uint64, seqID int64) (*types.KnowledgeTag, error)
// GetByIDs retrieves multiple tags by their IDs in a single query.
GetByIDs(ctx context.Context, tenantID uint64, ids []string) ([]*types.KnowledgeTag, error)
// GetBySeqIDs retrieves multiple tags by their seq_ids in a single query.
GetBySeqIDs(ctx context.Context, tenantID uint64, seqIDs []int64) ([]*types.KnowledgeTag, error)
GetByName(ctx context.Context, tenantID uint64, kbID string, name string) (*types.KnowledgeTag, error)
ListByKB(
ctx context.Context,
tenantID uint64,
kbID string,
page *types.Pagination,
keyword string,
) ([]*types.KnowledgeTag, int64, error)
Delete(ctx context.Context, tenantID uint64, id string) error
// CountReferences returns number of knowledges and chunks that reference the tag.
CountReferences(
ctx context.Context,
tenantID uint64,
kbID string,
tagID string,
) (knowledgeCount int64, chunkCount int64, err error)
// BatchCountReferences returns number of knowledges and chunks for multiple tags in a single query.
// Returns a map of tagID -> {knowledgeCount, chunkCount}
BatchCountReferences(
ctx context.Context,
tenantID uint64,
kbID string,
tagIDs []string,
) (map[string]types.TagReferenceCounts, error)
// DeleteUnusedTags deletes tags that are not referenced by any knowledge or chunk.
DeleteUnusedTags(ctx context.Context, tenantID uint64, kbID string) (int64, error)
}
@@ -0,0 +1,9 @@
package interfaces
import "github.com/hibiken/asynq"
// TaskEnqueuer abstracts task enqueueing. *asynq.Client satisfies this interface.
// For Lite mode (no Redis), a synchronous implementation dispatches tasks inline.
type TaskEnqueuer interface {
Enqueue(task *asynq.Task, opts ...asynq.Option) (*asynq.TaskInfo, error)
}
+13
View File
@@ -0,0 +1,13 @@
package interfaces
import (
"context"
"github.com/hibiken/asynq"
)
// TaskHandler is a interface for handling asynchronous tasks
type TaskHandler interface {
// Handle handles the task
Handle(ctx context.Context, t *asynq.Task) error
}
+112
View File
@@ -0,0 +1,112 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TaskInspector abstracts queue inspection / cancellation against the
// task backend. It is best-effort: implementations may scan a finite
// number of tasks per call and return whatever count they could
// affect. Lite mode (no Redis) ships a no-op implementation because
// SyncTaskExecutor dispatches inline goroutines that cannot be
// dequeued before they start.
//
// Use cases today: user-initiated cancel of an in-progress knowledge
// parse, which must remove downstream multimodal / post-process /
// question / summary tasks already enqueued against the same
// knowledge_id, plus signal active workers to stop at their next
// checkpoint.
type TaskInspector interface {
// CancelTasksForKnowledge removes pending/scheduled/retry tasks
// whose payload references the given knowledge ID, and signals
// active workers running such tasks to stop. Returns rough
// counts of (deletedFromQueue, activeCancelled) for observability.
// Errors are returned but callers should treat the operation as
// best-effort: the row-level abort flag remains the source of
// truth, this just prevents wasted work.
CancelTasksForKnowledge(ctx context.Context, knowledgeID string) (deleted int, cancelled int, err error)
// HasQueuedTasksForKnowledge reports whether any pending / scheduled
// / retry / active task referencing the given knowledge ID still
// lives in the queue backend. It is the read-only counterpart of
// CancelTasksForKnowledge: the housekeeping sweep calls it before
// flipping a long-idle "processing"/"finalizing" row to "failed" so
// it can tell a genuinely orphaned row (no task anywhere) from one
// whose enrichment subtasks are merely backlogged behind a busy
// queue (no span heartbeat yet because no worker has picked them up).
//
// Best-effort and short-circuiting: it returns true as soon as the
// first match is seen. On backend error it returns (false, err);
// callers decide the fail-safe direction. Lite mode (no Redis)
// always returns false — inline executors never queue, so the
// span/updated_at checks remain authoritative there.
HasQueuedTasksForKnowledge(ctx context.Context, knowledgeID string) (bool, error)
// QueueStats returns a read-only depth snapshot for every queue this
// application enqueues into, for the System Admin runtime dashboard.
//
// The `supported` bool reports whether queue inspection is available
// on the current backend: true in asynq/Redis mode, false in Lite
// mode (no Redis) where tasks run inline and no queue exists. Callers
// use it to render an "unavailable in this deployment" state rather
// than an empty table.
//
// Best-effort: a per-queue backend error is logged and that queue is
// surfaced as a zeroed row (so the full lane set stays visible) rather
// than failing the whole call.
QueueStats(ctx context.Context) (stats []types.QueueStat, supported bool, err error)
// WorkerServerStats returns live asynq server heartbeats across all
// replicas. The runtime dashboard uses them to aggregate actual cluster
// capacity and busy workers for each configured pool.
WorkerServerStats(ctx context.Context) (stats []types.WorkerServerStat, supported bool, err error)
}
// KnowledgeBaseTaskCanceller is the optional queue-cleanup capability used
// when a knowledge base is deleted. It is separate from TaskInspector so
// lightweight test doubles and queue backends that cannot inspect tasks do
// not need to implement knowledge-base-wide scanning.
type KnowledgeBaseTaskCanceller interface {
// CancelTasksForKnowledgeBase removes pending/scheduled/retry tasks
// associated with kbID and signals matching active workers to stop.
// knowledgeIDs covers batch tasks whose payload references documents but
// does not carry the parent knowledge-base ID. dataSourceIDs covers sync
// tasks whose payload only identifies a data source.
CancelTasksForKnowledgeBase(
ctx context.Context,
kbID string,
knowledgeIDs []string,
dataSourceIDs []string,
) (deleted int, cancelled int, err error)
}
// RuntimeTaskInspector is the optional operator surface implemented by queue
// backends that retain inspectable task state. It is separate from
// TaskInspector so Lite mode and light-weight tests do not need to implement
// queue mutations.
type RuntimeTaskInspector interface {
ListRuntimeTasks(
ctx context.Context,
queue string,
state types.RuntimeTaskState,
cursor string,
pageSize int,
) (page types.RuntimeTaskPage, supported bool, err error)
GetRuntimeTask(ctx context.Context, queue, taskID string) (task *types.RuntimeTaskInfo, supported bool, err error)
RunRuntimeTask(ctx context.Context, queue, taskID string) (supported bool, err error)
DeleteRuntimeTask(ctx context.Context, queue, taskID string) (supported bool, err error)
// ForceDeleteRuntimeTask removes a queue record without checking the
// operator-facing AllowedActions. Used when the business row is already
// gone but a retry/pending task survived (orphan cleanup).
ForceDeleteRuntimeTask(ctx context.Context, queue, taskID string) (supported bool, err error)
// PurgeArchivedRuntimeTasks removes every archived (finally-failed) task
// from one queue in a single call and returns how many records were
// deleted. It only ever touches the archived (dead-letter) set, so live
// pending/active/scheduled/retry tasks are never affected. Business state
// is intentionally left untouched: archived tasks have already had their
// document status flipped to "failed" on their last retry, mirroring the
// semantics of deleting archived records one by one.
PurgeArchivedRuntimeTasks(ctx context.Context, queue string) (deleted int, supported bool, err error)
}
+143
View File
@@ -0,0 +1,143 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// TaskPendingOpsRepository persists rows for the generic task pending
// queue (`task_pending_ops`). The queue is the durable replacement for
// the Redis-list-backed wiki:pending:<kbID> queue. It is intentionally
// stateless about consumer semantics: the (TaskType, Scope, ScopeID)
// tuple is the only routing primitive the repository understands;
// deduplication, batching, and retry policy live in the consumer.
//
// Concurrency model: two consumption primitives coexist.
// - PeekBatch does NOT take row locks; consumers enforce per-(scope_id)
// serialization out-of-band (e.g. an external Redis lock). This is the
// original primitive and is still used by single-process (Lite) mode.
// - ClaimBatch DOES take row locks (SELECT ... FOR UPDATE SKIP LOCKED on
// Postgres) and marks rows with claimed_at, so multiple concurrent
// consumers of the same tuple pull DISJOINT rows without an external
// lock. This is what lets the wiki pipeline drop its exclusive per-KB
// batch lock and spread one KB's backlog across the whole worker pool.
type TaskPendingOpsRepository interface {
// Enqueue inserts a single op. The caller fills in TenantID, TaskType,
// Scope, ScopeID, Op, DedupKey, Payload; ID, FailCount, EnqueuedAt
// are server-side defaults.
Enqueue(ctx context.Context, op *types.TaskPendingOp) error
// PeekBatch returns up to `limit` rows for the given queue tuple,
// ordered by id ASC (FIFO within the queue). Rows are NOT removed —
// callers must DeleteByIDs once the ops have been processed (or
// IncrFailCount and leave them for the next pass).
PeekBatch(ctx context.Context, taskType, scope, scopeID string, limit int) ([]*types.TaskPendingOp, error)
// ClaimBatch atomically claims eligible rows for the tuple, grouped by
// dedup_key, and returns them with claimed_at set to now. `limit`
// counts DISTINCT dedup_keys (documents), NOT rows: ALL eligible rows
// sharing a chosen dedup_key are claimed together so a document with
// several queued ops is never split across two concurrent batches.
// A row is eligible when it is unclaimed (claimed_at IS NULL) or its
// claim is stale (claimed_at < staleBefore) — the latter recovers rows
// abandoned by a crashed worker. On Postgres the per-key anchor row is
// locked with FOR UPDATE SKIP LOCKED so concurrent claimers take
// disjoint key sets without blocking or double-claiming.
//
// Claimed rows are NOT removed: the consumer must DeleteByIDs on
// success, or ReleaseByIDs to hand a still-retryable row back to the
// pool (otherwise it stays claimed until staleBefore elapses).
ClaimBatch(ctx context.Context, taskType, scope, scopeID string, limit int, staleBefore time.Time) ([]*types.TaskPendingOp, error)
// ReleaseByIDs clears claimed_at (back to NULL) for the given rows so
// a claimed-but-not-consumed row becomes immediately eligible for the
// next ClaimBatch. No-op for empty input. Used by the wiki retry path
// to re-queue a transiently-failed op without waiting for its claim to
// go stale. Harmless on rows that were never claimed (Lite mode).
ReleaseByIDs(ctx context.Context, ids []int64) error
// DeleteByIDs removes the given rows. No-op for empty input. Used
// to consume a successfully-processed batch, and to drop ops that
// have been moved to task_dead_letters.
DeleteByIDs(ctx context.Context, ids []int64) error
// IncrFailCount increments fail_count for one row and returns the
// new value. Returns (0, nil) if the row does not exist (race with
// DeleteByIDs is benign).
IncrFailCount(ctx context.Context, id int64) (int, error)
// PendingCount returns the number of rows currently queued for the
// given tuple. Cheap (covered by idx_task_pending_ops_scope) and
// used by the wiki ingest follow-up scheduler.
PendingCount(ctx context.Context, taskType, scope, scopeID string) (int64, error)
// DeleteByDedupKey removes rows for the tuple whose DedupKey
// matches. If `op` is non-empty, only rows with that exact op are
// removed (this lets wiki ingest scrub queued "ingest" ops while
// preserving "retract" ops for the same knowledge — retract is
// still needed to clean up wiki pages after the source doc is
// deleted). If `op` is empty, every matching row is removed
// regardless of op.
DeleteByDedupKey(ctx context.Context, taskType, scope, scopeID, dedupKey, op string) error
}
// TaskPendingOpsScopeCleaner is an optional extension for callers that need
// to discard every durable pending operation owned by a deleted scope. It is
// intentionally separate from TaskPendingOpsRepository so alternate queue
// implementations and existing test doubles are not forced to implement a
// lifecycle-only operation.
type TaskPendingOpsScopeCleaner interface {
DeleteByScope(ctx context.Context, scope, scopeID string) error
}
// TaskPendingOpsKnowledgeBaseGuard atomically persists a KB-scoped operation
// only while its knowledge base is still active. Implementations must
// serialize the active-KB check with soft deletion so a detached worker cannot
// recreate durable work after the deletion scrub has finished.
type TaskPendingOpsKnowledgeBaseGuard interface {
EnqueueIfKnowledgeBaseActive(ctx context.Context, op *types.TaskPendingOp) (accepted bool, err error)
}
// TaskPendingOpsFinalizingSeeder atomically hands a processing knowledge row
// to the asynchronous finalizing pipeline while persisting the durable
// pending operation that owns one of its subtask slots.
type TaskPendingOpsFinalizingSeeder interface {
SeedKnowledgeFinalizingWithPendingOp(
ctx context.Context,
knowledgeID string,
expectedSubtasks int,
op *types.TaskPendingOp,
) (promoted bool, err error)
}
// TaskDeadLetterRepository persists rows for the generic task dead-letter
// archive (`task_dead_letters`). Two writers exist: the asynq
// dead-letter middleware (one row per archived asynq task), and the
// service-level retry handlers (one row per in-batch op that exhausted
// its consumer-defined retry budget — wiki ingest is the current case).
//
// Reads are operator-driven: list by scope to triage a single KB,
// list by task_type for cross-KB symptom hunting. No TTL.
type TaskDeadLetterRepository interface {
// Insert records one dead letter. Best-effort caller: the asynq
// middleware ignores the error so a failed insert never masks the
// underlying task error.
Insert(ctx context.Context, dl *types.TaskDeadLetter) error
// ListByScope returns dead letters for the given scope tuple,
// newest-first, paginated by failed-id cursor. `cursor` is the
// stringified id of the oldest entry from the previous page; "" =
// from the newest. Empty nextCursor = end of stream. `limit` is
// clamped to [1, 200].
ListByScope(ctx context.Context, scope, scopeID, cursor string, limit int) ([]*types.TaskDeadLetter, string, error)
// ListByTaskType returns dead letters for the given task_type,
// newest-first, with the same cursor semantics as ListByScope.
ListByTaskType(ctx context.Context, taskType, cursor string, limit int) ([]*types.TaskDeadLetter, string, error)
// DeleteByID drops a single dead letter (e.g. after operators have
// requeued the task manually).
DeleteByID(ctx context.Context, id int64) error
}
@@ -0,0 +1,33 @@
package interfaces
import (
"context"
"io"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/hibiken/asynq"
)
type TemporaryDocumentRepository interface {
Create(ctx context.Context, document *types.TemporaryDocument) error
GetByID(ctx context.Context, tenantID uint64, documentID string) (*types.TemporaryDocument, error)
GetScoped(ctx context.Context, tenantID uint64, sessionID, documentID string) (*types.TemporaryDocument, error)
ListScoped(ctx context.Context, tenantID uint64, sessionID string) ([]*types.TemporaryDocument, error)
MarkProcessing(ctx context.Context, tenantID uint64, documentID string, startedAt time.Time) error
MarkReady(ctx context.Context, tenantID uint64, documentID, content string, chunks, imageRefs, metadata types.JSON, tokenCount, chunkCount int, readyAt time.Time) error
MarkFailed(ctx context.Context, tenantID uint64, documentID, message string) error
DeleteScoped(ctx context.Context, tenantID uint64, sessionID, documentID string) error
ListExpired(ctx context.Context, before time.Time, limit int) ([]*types.TemporaryDocument, error)
}
type TemporaryDocumentService interface {
Create(ctx context.Context, tenantID uint64, sessionID, fileName, mimeType string, fileSize int64, reader io.Reader, options types.TemporaryDocumentCreateOptions) (*types.TemporaryDocument, error)
Get(ctx context.Context, tenantID uint64, sessionID, documentID string) (*types.TemporaryDocument, error)
OpenFile(ctx context.Context, tenantID uint64, sessionID, documentID string) (io.ReadCloser, string, error)
List(ctx context.Context, tenantID uint64, sessionID string) ([]*types.TemporaryDocument, error)
Delete(ctx context.Context, tenantID uint64, sessionID, documentID string) error
ResolveForPrompt(ctx context.Context, tenantID uint64, sessionID string, documentIDs []string, query string) (*types.TemporaryDocumentPromptResult, error)
Process(ctx context.Context, task *asynq.Task) error
CleanupExpired(ctx context.Context) error
}
+123
View File
@@ -0,0 +1,123 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantService defines the tenant service interface
type TenantService interface {
// CreateTenant creates a tenant
CreateTenant(ctx context.Context, tenant *types.Tenant) (*types.Tenant, error)
// GetTenantByID gets a tenant by ID
GetTenantByID(ctx context.Context, id uint64) (*types.Tenant, error)
// GetTenantsByIDs batches GetTenantByID for multiple IDs in a single
// query. Returns a map keyed by tenant ID for O(1) lookup at the
// call site; missing tenants are simply absent from the map.
GetTenantsByIDs(ctx context.Context, ids []uint64) (map[uint64]*types.Tenant, error)
// ListTenants lists all tenants
ListTenants(ctx context.Context) ([]*types.Tenant, error)
// UpdateTenant updates a tenant
UpdateTenant(ctx context.Context, tenant *types.Tenant) (*types.Tenant, error)
// DeleteTenant deletes a tenant
DeleteTenant(ctx context.Context, id uint64) error
// ListAllTenants lists all tenants (for users with cross-tenant access permission)
ListAllTenants(ctx context.Context) ([]*types.Tenant, error)
// BulkSetStorageQuota overwrites every tenant's storage_quota with
// quotaBytes. Returns how many rows were affected. Used by the
// SystemAdmin "apply default to all tenants" action; bypasses the
// per-tenant whitelist on PUT /tenants/:id (which intentionally
// forbids storage_quota edits for Owners). quotaBytes must be > 0;
// callers are responsible for resolving GB→bytes.
BulkSetStorageQuota(ctx context.Context, quotaBytes int64) (int64, error)
// SearchTenants searches tenants with pagination and filters
SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error)
// GetTenantByIDForUser gets a tenant by ID with permission check
GetTenantByIDForUser(ctx context.Context, tenantID uint64, userID string) (*types.Tenant, error)
// GetWeKnoraCloudCredentials returns the decrypted WeKnoraCloud credentials for the current tenant.
GetWeKnoraCloudCredentials(ctx context.Context) *types.WeKnoraCloudCredentials
}
// TenantRepository defines the tenant repository interface
type TenantRepository interface {
// CreateTenant creates a tenant
CreateTenant(ctx context.Context, tenant *types.Tenant) error
// GetTenantByID gets a tenant by ID
GetTenantByID(ctx context.Context, id uint64) (*types.Tenant, error)
// GetTenantsByIDs batches GetTenantByID; see TenantService.GetTenantsByIDs.
GetTenantsByIDs(ctx context.Context, ids []uint64) (map[uint64]*types.Tenant, error)
// ListTenants lists all tenants
ListTenants(ctx context.Context) ([]*types.Tenant, error)
// SearchTenants searches tenants with pagination and filters
SearchTenants(ctx context.Context, keyword string, tenantID uint64, page, pageSize int) ([]*types.Tenant, int64, error)
// UpdateTenant updates a tenant
UpdateTenant(ctx context.Context, tenant *types.Tenant) error
// DeleteTenant deletes a tenant
DeleteTenant(ctx context.Context, id uint64) error
// AdjustStorageUsed adjusts the storage used for a tenant
AdjustStorageUsed(ctx context.Context, tenantID uint64, delta int64) error
// BulkSetStorageQuota — see TenantService.BulkSetStorageQuota.
BulkSetStorageQuota(ctx context.Context, quotaBytes int64) (int64, error)
}
type TenantAPIKeyCreateRequest struct {
TenantID uint64
ScopeType types.APIKeyScopeType
Name string
FullAccess bool
KnowledgeBaseIDs []string
Capabilities []string
ExpiresAt *time.Time
}
type TenantAPIKeyCreateResult struct {
APIKey *types.TenantAPIKey
Token string
}
// TenantAPIKeyUpdateRequest 修改已创建租户 API Key 的可配置属性。
// 配置语义与创建接口一致:FullAccess 为 true 时忽略细粒度能力和知识库范围。
type TenantAPIKeyUpdateRequest struct {
TenantID uint64
APIKeyID uint64
Name string
FullAccess bool
KnowledgeBaseIDs []string
Capabilities []string
ExpiresAt *time.Time
}
type TenantAPIKeyRepository interface {
CreateAPIKey(ctx context.Context, key *types.TenantAPIKey) error
GetAPIKeyByHash(ctx context.Context, hash string) (*types.TenantAPIKey, error)
ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error)
ListPlatformAPIKeys(ctx context.Context) ([]*types.TenantAPIKey, error)
UpdateAPIKey(ctx context.Context, tenantID uint64, id uint64, key *types.TenantAPIKey) (*types.TenantAPIKey, error)
RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error
RevokePlatformAPIKey(ctx context.Context, id uint64) error
UpdateAPIKeyHash(ctx context.Context, id uint64, hash string) error
UpdateAPIKeyLastUsed(ctx context.Context, id uint64, at time.Time) error
// ListKeysWithPlaceholderHash returns keys whose key_hash is still the
// migration placeholder (never authenticated since the 000065 upgrade),
// so the real SHA-256 hash can be computed and backfilled.
ListKeysWithPlaceholderHash(ctx context.Context) ([]*types.TenantAPIKey, error)
// HasKeysWithPlaceholderHash is a cheap existence probe used at startup
// to skip loading/decrypting api_key rows once backfill is complete.
HasKeysWithPlaceholderHash(ctx context.Context) (bool, error)
}
type TenantAPIKeyService interface {
CreateAPIKey(ctx context.Context, req TenantAPIKeyCreateRequest) (*TenantAPIKeyCreateResult, error)
AuthenticateAPIKey(ctx context.Context, token string) (*types.TenantAPIKey, error)
ListAPIKeys(ctx context.Context, tenantID uint64) ([]*types.TenantAPIKey, error)
ListPlatformAPIKeys(ctx context.Context) ([]*types.TenantAPIKey, error)
UpdateAPIKey(ctx context.Context, req TenantAPIKeyUpdateRequest) (*types.TenantAPIKey, error)
RevokeAPIKey(ctx context.Context, tenantID uint64, id uint64) error
RevokePlatformAPIKey(ctx context.Context, id uint64) error
// BackfillMissingKeyHashes computes and persists the SHA-256 key_hash
// for legacy keys still carrying the migration placeholder.
// Returns the number of keys backfilled.
BackfillMissingKeyHashes(ctx context.Context) (int, error)
}
@@ -0,0 +1,88 @@
package interfaces
import (
"context"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantInvitationRepository persists tenant_invitations rows that
// capture the "pending invite" intent before it becomes a real
// tenant_members row. All read methods skip GORM soft-deleted rows.
type TenantInvitationRepository interface {
// Create inserts a new pending invitation. Returns the conflict
// error sentinel (ErrPendingInvitationExists) if the (tenant_id,
// invitee) partial unique index rejects the insert.
Create(ctx context.Context, inv *types.TenantInvitation) error
// GetByID fetches an invitation by surrogate id, or (nil, nil) if
// missing. Used by Accept/Decline/Revoke handlers.
GetByID(ctx context.Context, id uint64) (*types.TenantInvitation, error)
// GetPendingByPair returns the in-flight pending invitation for
// (tenant, invitee) or (nil, nil) when none exists. Used by the
// service layer to short-circuit duplicate Create calls before
// they hit the unique index.
GetPendingByPair(ctx context.Context, tenantID uint64, inviteeUserID string) (*types.TenantInvitation, error)
// GetActiveByToken looks up the share-link row matching the
// supplied plaintext token. Returns (nil, nil) if no row matches
// or the row is no longer pending. "Active" rather than "pending
// for a single user" because share-link rows are multi-use:
// AcceptByToken does NOT consume them, only Revoke or expiry
// removes them from circulation.
GetActiveByToken(ctx context.Context, token string) (*types.TenantInvitation, error)
// ListByTenant returns invitations for the tenant ordered by
// id DESC (newest first). includeTerminal controls whether
// non-pending rows (accepted/declined/revoked/expired) are
// returned — the management UI shows pending by default, with
// a "history" toggle for the rest.
ListByTenant(ctx context.Context, tenantID uint64, includeTerminal bool) ([]*types.TenantInvitation, error)
// CountByTenantList counts invitation rows matching the same filter as
// ListByTenant (tenant_id + optional pending-only vs include-terminal).
CountByTenantList(ctx context.Context, tenantID uint64, includeTerminal bool) (int64, error)
// ListByTenantPage returns invitations for the tenant with id DESC
// paging, same filtering semantics as ListByTenant.
ListByTenantPage(ctx context.Context, tenantID uint64, includeTerminal bool, offset, limit int) ([]*types.TenantInvitation, error)
// ListByInvitee returns invitations addressed to the user across
// all tenants. Ordered by id DESC. Used both by the inbox page
// (includeTerminal=false) and by /me/invitations/history if/when
// we expose history (includeTerminal=true).
ListByInvitee(ctx context.Context, inviteeUserID string, includeTerminal bool) ([]*types.TenantInvitation, error)
// CountPendingByInvitee returns the number of pending invitations
// for the user. Used by the avatar-row badge so the UI doesn't
// have to fetch the full list just to render a count.
CountPendingByInvitee(ctx context.Context, inviteeUserID string) (int64, error)
// MarkStatusIfPending atomically transitions a row from pending
// to the supplied status. respondedAt is recorded as the
// transition time. Returns gorm.ErrRecordNotFound when the row
// does not exist or is already in a non-pending state, which the
// service layer maps to ErrInvitationNotPending — that error is
// the "stale state machine" signal callers care about.
MarkStatusIfPending(
ctx context.Context,
id uint64,
status types.TenantInvitationStatus,
respondedAt time.Time,
) error
// SweepExpired transitions all pending rows whose expires_at is
// before `now` into the expired state. Returns the affected row
// count so callers can decide whether to emit per-row audit
// events (we don't — the volume could be high and the audit-log
// table already records the original invitation_sent rows).
SweepExpired(ctx context.Context, now time.Time) (int64, error)
// IncrementAcceptedCount atomically bumps accepted_count by 1.
// Used by AcceptByToken so the management UI can show "N 人已通过
// 此链接加入" for share-link rows. Per-user invitations also call
// this on accept; the count just caps at 1 there.
IncrementAcceptedCount(ctx context.Context, id uint64) error
}
@@ -0,0 +1,104 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantInvitationService is the business-logic layer over
// TenantInvitationRepository. It enforces the lifecycle invariants
// described in TenantInvitationStatus, runs the lazy expiry sweep, and
// brokers the accept-time hand-off into TenantMemberService so that
// "accept" produces both the invitation transition AND the
// tenant_members row in a single audit story.
type TenantInvitationService interface {
// Create issues a new pending invitation for (tenantID, inviteeUserID)
// with the given role. Caller passes the inviter user id (may be
// nil for service-internal flows). Returns the freshly created row
// or a sentinel error (ErrPendingInvitationExists / ErrAlreadyMember /
// ErrInvalidTenantRole) for the common conflicts.
Create(
ctx context.Context,
tenantID uint64,
inviteeUserID string,
role types.TenantRole,
invitedBy *string,
message string,
) (*types.TenantInvitation, error)
// Accept transitions the pending row into accepted AND creates the
// active tenant_members row in the same transaction. The callerUserID
// MUST equal inv.InviteeUserID — the service rejects anyone else.
// Returns the freshly created membership for the response shape.
Accept(ctx context.Context, invID uint64, callerUserID string) (*types.TenantMember, error)
// Decline transitions the pending row into declined. Same caller
// rule as Accept: only the invitee themselves.
Decline(ctx context.Context, invID uint64, callerUserID string) error
// Revoke transitions the pending row into revoked. Callable by an
// Owner of the tenant the invitation belongs to (the handler
// applies the Owner gate via g.Owner() route middleware, so this
// method does not re-check role).
Revoke(ctx context.Context, invID uint64) error
// GetByID returns an invitation or (nil, nil). Used by handlers to
// authorise per-row operations (e.g. is the caller the invitee?).
GetByID(ctx context.Context, invID uint64) (*types.TenantInvitation, error)
// ListByTenant returns invitations for a tenant. Always runs the
// lazy expiry sweep before reading so stale-pending rows surface
// as expired in the UI.
ListByTenant(ctx context.Context, tenantID uint64, includeTerminal bool) ([]*types.TenantInvitation, error)
// ListTenantInvitationsPage paginates invitations for the tenant
// management UI after the lazy sweep (same filtering as ListByTenant).
ListTenantInvitationsPage(ctx context.Context, tenantID uint64, includeTerminal bool, page, pageSize int) ([]*types.TenantInvitation, int64, error)
// ListByInvitee returns invitations addressed to the user across
// all tenants. Always runs the lazy sweep first.
ListByInvitee(ctx context.Context, inviteeUserID string, includeTerminal bool) ([]*types.TenantInvitation, error)
// CountPendingByInvitee returns the user's pending invitation count
// after running the lazy sweep, so the inbox badge can poll a
// lightweight endpoint without paginating the full list.
CountPendingByInvitee(ctx context.Context, inviteeUserID string) (int64, error)
// CreateShareLink generates a multi-use share-link invitation for
// the tenant. The plaintext token is persisted on the row and also
// returned for the handler to compose the registration URL — the
// token stays available via list/get for as long as the row is
// pending so Owners can re-share without "copy now or lose it"
// pressure. Multiple share-link rows can coexist on the same
// tenant (different roles, or just multiple campaigns).
CreateShareLink(
ctx context.Context,
tenantID uint64,
role types.TenantRole,
invitedBy *string,
message string,
) (inv *types.TenantInvitation, plainToken string, err error)
// LookupByToken resolves a plaintext token to its active share-link
// row. Returns ErrInvitationTokenInvalid for unknown / expired /
// revoked tokens to avoid leaking which tokens used to exist. The
// row is NOT consumed; the same token can be looked up arbitrarily
// many times until it expires or the Owner revokes it.
LookupByToken(ctx context.Context, plainToken string) (*types.TenantInvitation, error)
// AcceptByToken creates a tenant_members row binding newUserID to
// the share-link's tenant + role. Unlike Accept, this does NOT flip
// the invitation row into a terminal state — share-link rows stay
// pending so subsequent invitees can also register through the
// same link. Idempotent: if the user already has membership, the
// existing row is returned. If the same user also has a pending direct
// invitation for the tenant, it is reconciled to accepted so the inbox
// cannot offer a second acceptance after membership already exists.
AcceptByToken(ctx context.Context, plainToken string, newUserID string) (*types.TenantMember, error)
// MarkPendingAcceptedIfExists transitions a per-user pending invitation
// into accepted when auto-accept joins the invitee without inbox
// confirmation. No-op when no pending row exists.
MarkPendingAcceptedIfExists(ctx context.Context, tenantID uint64, inviteeUserID string) error
}
@@ -0,0 +1,71 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantMemberRepository persists (user, tenant) membership rows that
// carry the per-tenant TenantRole.
//
// All methods operate on active rows only (deleted_at IS NULL) unless the
// docstring explicitly says otherwise. Soft deletion is handled by GORM
// via the DeletedAt field on TenantMember.
type TenantMemberRepository interface {
// Create inserts a new active membership row. Caller is responsible
// for ensuring no other active row exists for the same (user, tenant)
// pair; the partial unique index will return a conflict error otherwise.
Create(ctx context.Context, member *types.TenantMember) error
// Get returns the active membership for the given (user, tenant) pair,
// or (nil, nil) if no such row exists.
Get(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error)
// ListByUser returns every active membership owned by the given user,
// ordered by joined_at ascending.
ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error)
// ListByTenant returns every active membership inside the given tenant,
// ordered by joined_at ascending.
ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error)
// CountFilteredByTenant counts active memberships in tenant. Optional
// search matches user email/username (join users table); empty search
// counts all memberships.
CountFilteredByTenant(ctx context.Context, tenantID uint64, search string) (int64, error)
// ListPagedByTenant returns active memberships sorted joined_at ASC, id ASC.
// search filters by user email/username (join users table); empty
// search lists all memberships in tenant.
ListPagedByTenant(ctx context.Context, tenantID uint64, search string, offset, limit int) ([]*types.TenantMember, error)
// UpdateRole changes the role of an existing active membership. Returns
// gorm.ErrRecordNotFound if no active row matches.
UpdateRole(ctx context.Context, userID string, tenantID uint64, role types.TenantRole) error
// SoftDelete marks the active membership as deleted. The user record
// itself is untouched.
SoftDelete(ctx context.Context, userID string, tenantID uint64) error
// CountActiveOwners reports how many active rows in the tenant carry
// the owner role. Used by service-layer invariant checks ("cannot
// remove the last owner").
CountActiveOwners(ctx context.Context, tenantID uint64) (int64, error)
// HasAnyMembers reports whether the tenant has at least one active
// membership. Used by the auth middleware to decide whether to
// auto-promote the first authenticating human in an API-key-only tenant.
HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error)
// DemoteOwnerAtomically demotes an Owner to a non-Owner role inside
// a transaction that holds an UPDATE lock on the tenant's other
// active Owners, fixing the TOCTOU race where two concurrent
// demotions could leave the tenant ownerless. Returns the
// repo-level ErrLastOwner sentinel when no other Owner exists.
DemoteOwnerAtomically(ctx context.Context, userID string, tenantID uint64, newRole types.TenantRole) error
// RemoveOwnerAtomically soft-deletes an Owner row under the same
// lock as DemoteOwnerAtomically.
RemoveOwnerAtomically(ctx context.Context, userID string, tenantID uint64) error
}
@@ -0,0 +1,54 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// TenantMemberService is the business-logic layer over TenantMemberRepository.
// It enforces tenant-RBAC invariants such as "every tenant with members must
// keep at least one active Owner". HTTP handlers and other services call this
// interface rather than the repository directly so the invariants cannot be
// silently bypassed.
type TenantMemberService interface {
// AddMember inserts a new active membership row. Returns an error if
// (user, tenant) already has an active membership.
AddMember(ctx context.Context, userID string, tenantID uint64, role types.TenantRole, invitedBy *string) (*types.TenantMember, error)
// EnsureOwner is an idempotent helper used by the registration flow:
// if the user already has an active membership in the tenant, return
// it; otherwise create one with role=owner. This is the common path
// for self-service registration where the registrant becomes the
// Owner of the tenant their account just created.
EnsureOwner(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error)
// GetMembership returns the active (user, tenant) membership, or
// (nil, nil) if no such row exists.
GetMembership(ctx context.Context, userID string, tenantID uint64) (*types.TenantMember, error)
// ListByUser returns every active membership owned by the user.
ListByUser(ctx context.Context, userID string) ([]*types.TenantMember, error)
// ListByTenant returns every active membership inside the tenant.
ListByTenant(ctx context.Context, tenantID uint64) ([]*types.TenantMember, error)
// ListMembersPage lists members with pagination. Query matches email or
// username case-insensitively; empty query returns full tenant list slice.
ListMembersPage(ctx context.Context, tenantID uint64, query string, page, pageSize int) ([]*types.TenantMember, int64, error)
// HasAnyMembers reports whether the tenant has at least one active
// member. The auth middleware uses this to recover orphan tenants
// (e.g. API-key-only tenants that never had a human member): the
// first human authenticating into such a tenant is auto-promoted
// to Owner.
HasAnyMembers(ctx context.Context, tenantID uint64) (bool, error)
// UpdateRole changes the role of an existing membership while
// enforcing the "cannot demote the last active Owner" invariant.
UpdateRole(ctx context.Context, userID string, tenantID uint64, newRole types.TenantRole) error
// RemoveMember soft-deletes the membership while enforcing the
// "cannot remove the last active Owner" invariant.
RemoveMember(ctx context.Context, userID string, tenantID uint64) error
}
+150
View File
@@ -0,0 +1,150 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// UserService defines the user service interface
type UserService interface {
// Register creates a new user account
Register(ctx context.Context, req *types.RegisterRequest) (*types.User, error)
// Login authenticates a user and returns tokens
Login(ctx context.Context, req *types.LoginRequest) (*types.LoginResponse, error)
// GetOIDCAuthorizationURL builds the third-party OIDC authorization URL
GetOIDCAuthorizationURL(ctx context.Context, redirectURI string) (*types.OIDCAuthURLResponse, error)
// LoginWithOIDC exchanges the callback code, auto-provisions users if needed, and completes login.
// provisioning is the default tenant mode for a newly auto-created user
// (resolved by the caller from auth.default_tenant_mode).
LoginWithOIDC(ctx context.Context, code, redirectURI string, provisioning types.TenantProvisioningMode) (*types.OIDCCallbackResponse, error)
// GetUserByID gets a user by ID
GetUserByID(ctx context.Context, id string) (*types.User, error)
// GetUsersByIDs batch-fetches users by id, returning a map keyed by
// user id. Missing ids are simply absent from the result; the call
// is not an error when some ids resolve to no row. Used on hot list
// endpoints (tenant members, audit logs) to avoid N+1 queries.
GetUsersByIDs(ctx context.Context, ids []string) (map[string]*types.User, error)
// GetUserByEmail gets a user by email
GetUserByEmail(ctx context.Context, email string) (*types.User, error)
// GetUserByUsername gets a user by username
GetUserByUsername(ctx context.Context, username string) (*types.User, error)
// GetUserByTenantID gets the first user (owner) of a tenant
GetUserByTenantID(ctx context.Context, tenantID uint64) (*types.User, error)
// UpdateUser updates user information
UpdateUser(ctx context.Context, user *types.User) error
// DeleteUser deletes a user
DeleteUser(ctx context.Context, id string) error
// ChangePassword changes user password
ChangePassword(ctx context.Context, userID string, oldPassword, newPassword string) error
// AdminResetPassword replaces a user's password without requiring the old
// password and revokes all of that user's existing sessions. Callers must
// enforce the system-admin and cannot-reset-self guards before invoking it.
AdminResetPassword(ctx context.Context, userID string, newPassword string) error
// ValidatePassword validates user password
ValidatePassword(ctx context.Context, userID string, password string) error
// GenerateTokens generates access and refresh tokens for user
GenerateTokens(ctx context.Context, user *types.User) (accessToken, refreshToken string, err error)
// BuildLoginMemberships projects the user's tenant memberships into
// the login-response shape. activeTenant is reused (without an extra
// lookup) for the matching row's TenantName. The slice is guaranteed
// non-nil so callers can serialise it as an empty JSON array when the
// membership table is unavailable.
BuildLoginMemberships(ctx context.Context, user *types.User, activeTenant *types.Tenant) []types.Membership
// SwitchTenant issues a new token pair scoped to targetTenantID and
// returns the corresponding LoginResponse. The caller's previous
// refresh token (passed in for revocation) is invalidated. Membership
// is verified via the TenantMember service before tokens are issued.
// On success the target is recorded as the user's last-active-tenant
// preference (next login and refresh land there). A preference-write
// failure fails the switch so no token pair is issued.
SwitchTenant(ctx context.Context, user *types.User, targetTenantID uint64, currentRefreshToken string) (*types.LoginResponse, error)
// ValidateToken validates an access token. It returns the user
// referenced by the token plus the active tenant ID encoded in the
// JWT's `tenant_id` claim — the latter lets the auth middleware
// honour /auth/switch-tenant sessions that were minted with a
// non-home tenant. Falls back to user.TenantID when the claim is
// missing (old tokens issued before tenant-level RBAC).
ValidateToken(ctx context.Context, token string) (*types.User, uint64, error)
// RefreshToken refreshes access token using refresh token
RefreshToken(ctx context.Context, refreshToken string) (accessToken, newRefreshToken string, err error)
// RevokeToken revokes a token
RevokeToken(ctx context.Context, token string) error
// Logout revokes every outstanding access/refresh token for the user
// identified by the presented JWT.
Logout(ctx context.Context, token string) error
// GetCurrentUser gets current user from context
GetCurrentUser(ctx context.Context) (*types.User, error)
// SearchUsers searches users by username or email
SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error)
// ListSystemAdmins lists users with IsSystemAdmin=true.
// Returns the page of admins plus the total count (for pagination UI);
// callers pass offset/limit to page through results. Used by the
// /api/v1/system/admin/list endpoint, gated to SystemAdmin callers.
ListSystemAdmins(ctx context.Context, offset, limit int) ([]*types.User, int64, error)
// AdminCreateUser provisions a new local user on behalf of a
// SystemAdmin. When req.Password is nil, a random password is generated
// and returned exactly once as the second result. provisioning is
// resolved by the caller from the shared auth.default_tenant_mode policy.
AdminCreateUser(
ctx context.Context, req *types.AdminCreateUserRequest, provisioning types.TenantProvisioningMode,
) (*types.User, string, error)
// RevokeSystemAdmin removes system-admin privileges with the
// last-admin/self-revoke checks performed atomically.
RevokeSystemAdmin(ctx context.Context, userID, actorID string) (*types.User, error)
// UpdateUserPreferences partially updates the calling user's
// preferences blob (PATCH semantics: only keys present in `patch`
// overwrite existing values). Returns the updated, persisted prefs.
UpdateUserPreferences(ctx context.Context, userID string, patch types.UserPreferences) (types.UserPreferences, error)
}
// UserRepository defines the user repository interface
type UserRepository interface {
// CreateUser creates a user
CreateUser(ctx context.Context, user *types.User) error
// GetUserByID gets a user by ID
GetUserByID(ctx context.Context, id string) (*types.User, error)
// GetUsersByIDs batch-fetches users by id, returning a map keyed by
// user id. Missing ids are simply absent from the result.
GetUsersByIDs(ctx context.Context, ids []string) (map[string]*types.User, error)
// GetUserByEmail gets a user by email
GetUserByEmail(ctx context.Context, email string) (*types.User, error)
// GetUserByUsername gets a user by username
GetUserByUsername(ctx context.Context, username string) (*types.User, error)
// GetUserByTenantID gets the first user (owner) of a tenant
GetUserByTenantID(ctx context.Context, tenantID uint64) (*types.User, error)
// UpdateUser updates a user
UpdateUser(ctx context.Context, user *types.User) error
// DeleteUser deletes a user
DeleteUser(ctx context.Context, id string) error
// ListUsers lists users with pagination
ListUsers(ctx context.Context, offset, limit int) ([]*types.User, error)
// ListSystemAdmins lists users where is_system_admin = true.
// Walks the partial-friendly idx_users_is_system_admin index. Returns
// the slice plus the total count for pagination metadata. Used by
// the system-admin management endpoint.
ListSystemAdmins(ctx context.Context, offset, limit int) ([]*types.User, int64, error)
// RevokeSystemAdmin removes system-admin privileges with the
// last-admin/self-revoke checks performed atomically.
RevokeSystemAdmin(ctx context.Context, userID, actorID string) (*types.User, error)
// SearchUsers searches users by username or email
SearchUsers(ctx context.Context, query string, limit int) ([]*types.User, error)
}
// AuthTokenRepository defines the auth token repository interface
type AuthTokenRepository interface {
// CreateToken creates an auth token
CreateToken(ctx context.Context, token *types.AuthToken) error
// GetTokenByValue gets a token by its value
GetTokenByValue(ctx context.Context, tokenValue string) (*types.AuthToken, error)
// GetTokensByUserID gets all tokens for a user
GetTokensByUserID(ctx context.Context, userID string) ([]*types.AuthToken, error)
// UpdateToken updates a token
UpdateToken(ctx context.Context, token *types.AuthToken) error
// DeleteToken deletes a token
DeleteToken(ctx context.Context, id string) error
// DeleteExpiredTokens deletes all expired tokens
DeleteExpiredTokens(ctx context.Context) error
// RevokeTokensByUserID revokes all tokens for a user
RevokeTokensByUserID(ctx context.Context, userID string) error
}
@@ -0,0 +1,33 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// UserResourceFavoriteRepository is the storage contract for per-user
// favorited resources. Implementations are expected to be tenant-aware
// (queries always include tenant_id) so a user switching tenants sees
// only that tenant's favorites — matches the PK shape in migration 000047.
type UserResourceFavoriteRepository interface {
// List returns favorites for (userID, tenantID, resourceType), newest first.
List(ctx context.Context, userID string, tenantID uint64, resourceType string) ([]*types.UserResourceFavorite, error)
// Add upserts a favorite (no-op if already present). Returns whether
// a new row was actually inserted so the service layer can decide
// whether to emit an audit event.
Add(ctx context.Context, userID string, tenantID uint64, resourceType, resourceID string) (created bool, err error)
// Remove deletes a favorite; returns whether anything was deleted.
Remove(ctx context.Context, userID string, tenantID uint64, resourceType, resourceID string) (removed bool, err error)
// IsFavorite exists for the rare RPC where the caller wants a single
// boolean rather than the full list (e.g. a future GET-by-id endpoint).
IsFavorite(ctx context.Context, userID string, tenantID uint64, resourceType, resourceID string) (bool, error)
}
// UserResourceFavoriteService wraps the repository with input validation
// (resource type allowlist, non-empty id) so the handler stays thin.
type UserResourceFavoriteService interface {
List(ctx context.Context, userID string, tenantID uint64, resourceType string) ([]*types.UserResourceFavorite, error)
Add(ctx context.Context, userID string, tenantID uint64, resourceType, resourceID string) error
Remove(ctx context.Context, userID string, tenantID uint64, resourceType, resourceID string) error
}
+95
View File
@@ -0,0 +1,95 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// StoreRegistry provides VectorStore-based engine registration/lookup.
// Separated from RetrieveEngineRegistry to avoid changing the existing interface
// used by 6 services (17 call sites). Phase 2 may merge into RetrieveEngineRegistry.
type StoreRegistry interface {
// RegisterWithStoreID registers an engine service by VectorStore ID.
// Upsert semantics: existing entry is overwritten silently.
RegisterWithStoreID(storeID string, svc RetrieveEngineService)
// GetByStoreID retrieves an engine service by VectorStore ID.
GetByStoreID(storeID string) (RetrieveEngineService, error)
// UnregisterByStoreID removes an engine service by VectorStore ID (idempotent).
UnregisterByStoreID(storeID string)
}
// EngineFactory creates a RetrieveEngineService from a VectorStore's config.
// Defined as a function type to avoid circular imports between container and service packages.
type EngineFactory func(ctx context.Context, store types.VectorStore) (RetrieveEngineService, error)
// VectorStoreService defines the service interface for vector store management.
// Tenant isolation is enforced by the handler layer (getOwnedStore pattern).
type VectorStoreService interface {
// CreateStore validates and creates a new vector store.
CreateStore(ctx context.Context, store *types.VectorStore) error
// UpdateStore updates an existing vector store (name only).
UpdateStore(ctx context.Context, store *types.VectorStore) error
// DeleteStore deletes a vector store by tenant + id.
// Rejects deletion when any active knowledge base is bound to the store
// (binding guard); the caller must unbind or delete those KBs first.
DeleteStore(ctx context.Context, tenantID uint64, id string) error
// TestConnection tests connectivity to a vector database.
// Returns the detected server version on success (e.g., "7.10.1"), empty string if unknown.
//
// Validation-free: intended for trusted configs (env stores, stored
// configs already validated at create time). Handlers receiving raw
// user input MUST use TestRawConnection instead.
TestConnection(ctx context.Context, engineType types.RetrieverEngineType, config types.ConnectionConfig) (string, error)
// TestRawConnection validates raw user-supplied connection config
// (engine-type allowlist, required fields, SSRF policy) and then delegates
// to TestConnection. This is the entry point for unpersisted user input.
TestRawConnection(ctx context.Context, engineType types.RetrieverEngineType, config types.ConnectionConfig) (string, error)
// SaveDetectedVersion updates the connection_config.version for a stored vector store.
SaveDetectedVersion(ctx context.Context, store *types.VectorStore, version string) error
// ResolveStoreView returns the API-safe display projection of a single
// store ID, scoped to the given tenant. Tries DB stores first, then the
// cached env-store set. Returns types.UnavailableStoreDisplay() when the
// store cannot be resolved — callers should still succeed in such cases
// (the response carries a "unavailable" status signal for the UI).
//
// Never returns connection credentials in any form: the StoreDisplay
// payload carries only Name / Source / EngineType / Status.
ResolveStoreView(ctx context.Context, tenantID uint64, storeID string) (types.StoreDisplay, error)
// BatchResolveStoreView resolves multiple store IDs in a single DB read
// plus the cached env-store match. Returned map keys are the storeIDs
// originally requested; missing IDs map to types.UnavailableStoreDisplay().
//
// Intended for list endpoints that need store metadata for many KBs at
// once without incurring N+1 ResolveStoreView calls.
BatchResolveStoreView(ctx context.Context, tenantID uint64, storeIDs []string) (map[string]types.StoreDisplay, error)
// EnvDefaultStoreView returns the display payload for KBs that fall
// back to the env-configured store. Unlike DefaultStoreDisplay() in
// the types package, this method also fills the engine type
// (e.g. "postgres") so UIs can render the same badge shape for
// env-bound and user-bound KBs. Independent of ResolveStoreView so
// list paths can use it without violating the "no per-KB
// ResolveStoreView" invariant.
EnvDefaultStoreView(ctx context.Context) types.StoreDisplay
}
// VectorStoreRepository defines the repository interface for VectorStore CRUD.
type VectorStoreRepository interface {
// Create creates a new vector store
Create(ctx context.Context, store *types.VectorStore) error
// GetByID retrieves a vector store by ID within a tenant scope
GetByID(ctx context.Context, tenantID uint64, id string) (*types.VectorStore, error)
// List lists all vector stores for a tenant
List(ctx context.Context, tenantID uint64) ([]*types.VectorStore, error)
// Update updates a vector store (only mutable fields: name)
Update(ctx context.Context, store *types.VectorStore) error
// UpdateConnectionConfig updates only the connection_config column
UpdateConnectionConfig(ctx context.Context, store *types.VectorStore) error
// Delete soft-deletes a vector store
Delete(ctx context.Context, tenantID uint64, id string) error
// ExistsByEndpointAndIndex checks if a store with the same endpoint and index already exists
ExistsByEndpointAndIndex(ctx context.Context, tenantID uint64, engineType types.RetrieverEngineType, endpoint string, indexName string) (bool, error)
}
+29
View File
@@ -0,0 +1,29 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchProvider defines the interface for web search providers
type WebSearchProvider interface {
// Name returns the name of the provider
Name() string
// Search performs a web search
Search(ctx context.Context, query string, maxResults int, includeDate bool) ([]*types.WebSearchResult, error)
}
// WebSearchService defines the interface for web search services
type WebSearchService interface {
// Search performs a web search using the provider entity identified by providerID.
// If providerID is empty, it falls back to the deprecated config.Provider field for backward compatibility.
Search(ctx context.Context, providerID string, config *types.WebSearchConfig, query string) ([]*types.WebSearchResult, error)
// CompressWithRAG performs RAG-based compression using a temporary, hidden knowledge base
// The temporary knowledge base is deleted after use. The UI will not list it due to repo filtering.
CompressWithRAG(ctx context.Context, sessionID string, tempKBID string, questions []string,
webSearchResults []*types.WebSearchResult, cfg *types.WebSearchConfig,
kbSvc KnowledgeBaseService, knowSvc KnowledgeService,
seenURLs map[string]bool, knowledgeIDs []string,
) (compressed []*types.WebSearchResult, kbID string, newSeen map[string]bool, newIDs []string, err error)
}
@@ -0,0 +1,49 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// WebSearchProviderRepository defines the repository interface for web search provider CRUD
type WebSearchProviderRepository interface {
// Create creates a new web search provider
Create(ctx context.Context, provider *types.WebSearchProviderEntity) error
// GetByID retrieves a web search provider by ID within a tenant scope
GetByID(ctx context.Context, tenantID uint64, id string) (*types.WebSearchProviderEntity, error)
// GetDefault retrieves the default provider (is_default=true) for a tenant, or nil if none.
GetDefault(ctx context.Context, tenantID uint64) (*types.WebSearchProviderEntity, error)
// List lists all web search providers for a tenant
List(ctx context.Context, tenantID uint64) ([]*types.WebSearchProviderEntity, error)
// Update updates a web search provider
Update(ctx context.Context, provider *types.WebSearchProviderEntity) error
// Delete deletes a web search provider (soft delete)
Delete(ctx context.Context, tenantID uint64, id string) error
// ClearDefault clears the default flag for all providers of a tenant, optionally excluding one
ClearDefault(ctx context.Context, tenantID uint64, excludeID string) error
}
// WebSearchProviderService defines the service interface for web search provider management.
// Tenant isolation is enforced by the handler layer (getOwned pattern).
// Service methods operate on entities whose TenantID is already verified.
type WebSearchProviderService interface {
// CreateProvider creates a new web search provider.
// provider.TenantID must be set by the caller (handler).
CreateProvider(ctx context.Context, provider *types.WebSearchProviderEntity) error
// UpdateProvider updates an existing provider.
// provider.TenantID must be set by the caller (handler) for the repository WHERE clause.
UpdateProvider(ctx context.Context, provider *types.WebSearchProviderEntity) error
// DeleteProvider deletes a provider by tenant + id.
DeleteProvider(ctx context.Context, tenantID uint64, id string) error
// UpdateProviderCredentials writes one or more credential fields.
// apiKey nil means "do not touch"; empty string is a no-op (clearing
// goes through ClearProviderCredential). Returns the updated entity.
UpdateProviderCredentials(
ctx context.Context, tenantID uint64, id string, apiKey *string,
) (*types.WebSearchProviderEntity, error)
// ClearProviderCredential removes a single credential field. Currently
// only "api_key" is recognized. Idempotent on already-empty fields.
ClearProviderCredential(ctx context.Context, tenantID uint64, id, field string) error
}
@@ -0,0 +1,26 @@
package interfaces
import (
"context"
)
// WebSearchStateService defines the service interface for managing web search temporary KB state
type WebSearchStateService interface {
// GetWebSearchTempKBState retrieves the temporary KB state for web search from Redis
GetWebSearchTempKBState(
ctx context.Context,
sessionID string,
) (tempKBID string, seenURLs map[string]bool, knowledgeIDs []string)
// SaveWebSearchTempKBState saves the temporary KB state for web search to Redis
SaveWebSearchTempKBState(
ctx context.Context,
sessionID string,
tempKBID string,
seenURLs map[string]bool,
knowledgeIDs []string,
)
// DeleteWebSearchTempKBState deletes the temporary KB state for web search from Redis
DeleteWebSearchTempKBState(ctx context.Context, sessionID string) error
}
+16
View File
@@ -0,0 +1,16 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// WeKnoraCloudService 处理 WeKnoraCloud 凭证管理
type WeKnoraCloudService interface {
// SaveCredentials 仅保存 APPID/APPSECRET 凭证到空间配置,不自动创建模型
SaveCredentials(ctx context.Context, appID, appSecret string) error
// CheckStatus 检查当前空间的 WeKnoraCloud 凭证是否可正常解密
// needsReinit=true 表示加密状态已损坏(salt 变更等),需要用户重新填写凭证
CheckStatus(ctx context.Context) (*types.WeKnoraCloudStatusResult, error)
}
+429
View File
@@ -0,0 +1,429 @@
package interfaces
import (
"context"
"github.com/Tencent/WeKnora/internal/types"
)
// WikiPageService defines the wiki page service interface.
// Provides high-level operations for wiki page CRUD, link management,
// and chunk synchronization.
type WikiPageService interface {
// CreatePage creates a new wiki page, parses outbound links, updates
// bidirectional link references, and syncs to chunks for retrieval.
CreatePage(ctx context.Context, page *types.WikiPage) (*types.WikiPage, error)
// UpdatePage updates an existing wiki page, re-parses links, and updates
// bidirectional references. The `version` field is incremented only when
// a user-visible content field (title, content, summary, page_type,
// status) actually differs from the stored value — bookkeeping-only
// writes (e.g. refreshing source_refs after a same-content re-ingest) are
// persisted without bumping the version.
UpdatePage(ctx context.Context, page *types.WikiPage) (*types.WikiPage, error)
// UpdatePageMeta updates only metadata (status, source_refs, etc.) without
// incrementing version or re-parsing links. Use for publish/archive/source
// ref changes driven by internal reconciliation.
UpdatePageMeta(ctx context.Context, page *types.WikiPage) error
// UpdateAutoLinkedContent persists content changes that come from
// machine-only link decoration — cross-link injection and dead-link
// cleanup — without bumping `version`. Out-links are re-parsed from the
// new body and bidirectional in-link references on target pages are
// refreshed. Conceptually the body still represents the same revision
// the user saw before, just with wiki-link markup added or stripped, so
// surfacing a version bump to end users is misleading.
UpdateAutoLinkedContent(ctx context.Context, page *types.WikiPage) error
// GetPageBySlug retrieves a wiki page by its slug within a knowledge base.
GetPageBySlug(ctx context.Context, kbID string, slug string) (*types.WikiPage, error)
// RepairContentLinks rewrites dead [[slug]] references in content to their
// most likely live target (rewrite-only — never strips). Used by the agent
// write path to auto-correct LLM-mangled slugs (especially UUID-based
// summary slugs) before persistence. Returns the possibly-updated content
// and whether any rewrite happened. Best-effort: callers may ignore errors.
RepairContentLinks(ctx context.Context, kbID, selfSlug, content string) (string, bool, error)
// GetPageByID retrieves a wiki page by its unique ID.
GetPageByID(ctx context.Context, id string) (*types.WikiPage, error)
// ListPages lists wiki pages with optional filtering and pagination.
ListPages(ctx context.Context, req *types.WikiPageListRequest) (*types.WikiPageListResponse, error)
// DeletePage soft-deletes a wiki page and removes its chunk sync.
DeletePage(ctx context.Context, kbID string, slug string) error
// GetIndex returns the index page for a knowledge base.
// Creates a default one if it doesn't exist.
//
// The index row now carries only the LLM-generated intro in its content
// column — the heavy directory listing was moved out of wiki_pages.
// Callers that want a renderable directory should use GetIndexView,
// which assembles {intro, groups} structured output on demand.
GetIndex(ctx context.Context, kbID string) (*types.WikiPage, error)
// GetIndexView returns the structured index response — intro (from the
// index row) plus a paginated window of directory entries per requested
// page_type. `types` restricts which page types to include (empty =
// all), `limit` bounds the per-type window size, `cursor` is an opaque
// offset string resumed from a previous response.
//
// Keeping the directory as JSON rather than a multi-MB markdown blob
// means a 40k-page KB no longer pays O(N) transport + rendering cost
// on every index open. See /wiki/index handler + WikiBrowser.vue for
// the consumer.
GetIndexView(ctx context.Context, kbID string, pageTypes []string, limit int, cursor string) (*types.WikiIndexResponse, error)
// GetGraph returns the link graph data for visualization. The caller
// supplies a WikiGraphRequest describing the desired slice of the graph
// (overview top-N or ego neighborhood around a center slug). Callers
// that need the full graph (e.g. wiki lint) can set Limit <= 0 to
// disable the node cap; the HTTP handler always clamps Limit to a
// safe range before invoking the service.
GetGraph(ctx context.Context, req *types.WikiGraphRequest) (*types.WikiGraphData, error)
// GetStats returns aggregate statistics about the wiki.
GetStats(ctx context.Context, kbID string) (*types.WikiStats, error)
// RebuildLinks re-parses all pages and rebuilds bidirectional link references.
RebuildLinks(ctx context.Context, kbID string) error
// InjectCrossLinks scans specified pages and injects [[wiki-links]] for mentions
// of other wiki page titles/aliases in the content.
InjectCrossLinks(ctx context.Context, kbID string, affectedSlugs []string)
// RebuildIndexPage regenerates the index page.
RebuildIndexPage(ctx context.Context, kbID string) error
// ListAllPages retrieves all non-archived wiki pages in a knowledge base without pagination.
// Used for index rebuild, graph generation, cross-link injection, etc.
ListAllPages(ctx context.Context, kbID string) ([]*types.WikiPage, error)
// ListByType returns every wiki page of the given type. Used by
// callers that need the full set for a specific type (e.g. intro
// regeneration needs summary pages). For directory rendering, use
// GetIndexView instead — it paginates via ListByTypeLight.
ListByType(ctx context.Context, kbID string, pageType string) ([]*types.WikiPage, error)
// ListPagesBySourceRef retrieves all wiki pages whose source_refs reference
// the given knowledge ID. Used by delete/ingest reconciliation paths that
// need to find pages touched by a specific document at read time (rather
// than relying on a caller-provided stale snapshot).
ListPagesBySourceRef(ctx context.Context, kbID string, knowledgeID string) ([]*types.WikiPage, error)
// ListSlugsBySourceRef returns just the slugs of pages that cite the
// given knowledge id. Cheaper than ListPagesBySourceRef when callers
// only need the slug set (e.g. wiki ingest's "before" snapshot).
ListSlugsBySourceRef(ctx context.Context, kbID string, knowledgeID string) ([]string, error)
// ListBySlugs is the lazy fetcher used by the wiki ingest batch
// context: returns lightweight projections (no content / source_refs)
// for the requested slugs in a single IN query. Used in place of
// the pre-batch ListAllPages dump.
ListBySlugs(ctx context.Context, kbID string, slugs []string) (map[string]*types.WikiPageLite, error)
// ListSummariesByKnowledgeIDs returns summary-page content keyed by
// the knowledge id that authored it. Used by the retract / reparse
// branches of reduceSlugUpdates for "what was this doc's
// contribution?" framing.
ListSummariesByKnowledgeIDs(ctx context.Context, kbID string, kids []string) (map[string]string, error)
// ExistsSlugs reports which of the given slugs are live (non-archived).
// Used by cleanDeadLinks to validate out-link targets cheaply.
ExistsSlugs(ctx context.Context, kbID string, slugs []string) (map[string]bool, error)
// ListAllSlugs returns every non-archived slug in the KB. Used by
// lint to compute the live-slug set for broken-link detection.
ListAllSlugs(ctx context.Context, kbID string) ([]string, error)
// ListPagesCursor walks the KB in id-asc order with an opaque
// cursor. Used by lint to stream the page set without holding it
// all in memory at once.
ListPagesCursor(ctx context.Context, kbID string, cursor string, limit int) ([]*types.WikiPage, string, error)
// ListByTypeRecent returns the most-recently-updated pages of the
// given type, projected to slug/title/summary, capped at `limit`.
// Used by rebuildIndexPage's first-time generation path.
ListByTypeRecent(ctx context.Context, kbID string, pageType string, limit int) ([]types.WikiIndexEntry, error)
// FindSimilarPages performs a pg_trgm similarity search over
// page titles. Used by the dedup pre-filter to surface candidate
// merge targets server-side.
FindSimilarPages(ctx context.Context, kbID string, query string, pageTypes []string, limit int) ([]*types.WikiPageLite, error)
// FindPagesByNormalizedTitle returns non-archived pages of pageType whose
// display title matches identity after the same whitespace/case fold used
// by wiki ingest identity claims. Used so exact same-title pages are found
// even when they miss the trigram top-K.
FindPagesByNormalizedTitle(ctx context.Context, kbID, pageType, identity string) ([]*types.WikiPageLite, error)
// FindPagesByNormalizedTitles is the batched form of
// FindPagesByNormalizedTitle. identities are already whitespace-stripped
// and lowercased; empty entries are ignored.
FindPagesByNormalizedTitles(ctx context.Context, kbID, pageType string, identities []string) ([]*types.WikiPageLite, error)
// ListDistinctCategoryPaths returns the existing wiki folder paths (split
// into segments), capped at maxPaths. Used by wiki ingest's taxonomy
// planner as the pool of folders to reuse.
ListDistinctCategoryPaths(ctx context.Context, kbID string, maxPaths int) ([][]string, error)
// --- Folder tree (wiki_folders) ---
// ListChildFolders returns the direct child folders of parentID ("" =
// root) relevant to the given page types, each enriched with a recursive
// page count (the folder's whole subtree, restricted to pageTypes) and
// whether it has relevant sub-folders. A folder is shown when its subtree
// holds at least one page of pageTypes, or when it is entirely empty (a
// user-created container with no pages of any type). When pageTypes is
// empty every type counts.
ListChildFolders(
ctx context.Context, kbID string, parentID string, pageTypes []string,
) ([]types.WikiFolderNode, error)
// GetFolder retrieves a single folder by id.
GetFolder(ctx context.Context, kbID string, id string) (*types.WikiFolder, error)
// CreateFolder creates a new (initially empty) folder under parentID and
// returns it. Fails if a sibling with the same name already exists.
CreateFolder(ctx context.Context, kbID string, tenantID uint64, parentID string, name string) (*types.WikiFolder, error)
// RenameOrMoveFolder renames and/or reparents a folder, recomputing the
// materialized path/depth of the whole subtree and the cached paths of
// every page underneath. Returns the updated folder.
RenameOrMoveFolder(ctx context.Context, kbID string, id string, newName string, newParentID string, moveParent bool) (*types.WikiFolder, error)
// DeleteFolder removes an empty folder. Fails if it still contains pages
// or child folders (the UI must move or delete contents first).
DeleteFolder(ctx context.Context, kbID string, id string) error
// PruneEmptyFolderChains deletes candidate folders that are still empty,
// walking upward through newly-empty ancestors. Candidates are supplied by
// document retract cleanup so unrelated user-created empty folders remain.
PruneEmptyFolderChains(ctx context.Context, kbID string, folderIDs []string) ([]string, error)
// FindOrCreateFolderPath resolves a category path (e.g. ["AI","RAG"]) to a
// folder id, creating any missing intermediate folders. Returns the leaf
// folder id and the canonical (cleaned) path. An empty/blank path resolves
// to the root ("").
FindOrCreateFolderPath(ctx context.Context, kbID string, tenantID uint64, path []string) (string, []string, error)
// MovePage relocates a page into folderID ("" = root), recomputing its
// cached category path. Returns the updated page.
MovePage(ctx context.Context, kbID string, slug string, folderID string) (*types.WikiPage, error)
// CountByType returns page counts grouped by type for a knowledge
// base. Re-exposed at the service layer so the index intro
// generation path can frame the LLM prompt with "showing N of M"
// metadata when the recent-N projection doesn't cover the full
// summary set.
CountByType(ctx context.Context, kbID string) (map[string]int64, error)
// SearchPages performs full-text search over wiki pages.
SearchPages(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error)
// ListRevisions returns the stored historical snapshots for a page
// (newest first, content omitted) plus the total snapshot count and the
// page's current version. The current version itself has no revision
// row — it lives in wiki_pages.
ListRevisions(ctx context.Context, kbID string, slug string, limit int, offset int) (*types.WikiPageRevisionListResponse, error)
// GetRevision returns one historical snapshot (content included).
GetRevision(ctx context.Context, kbID string, slug string, version int) (*types.WikiPageRevision, error)
// RevertPageToVersion rolls the page back to the content of the given
// stored revision by applying it as a regular edit: the pre-revert state
// is snapshotted, version advances, and links are re-parsed. The edit is
// attributed to WikiEditSourceRevert.
RevertPageToVersion(ctx context.Context, kbID string, slug string, version int) (*types.WikiPage, error)
// CreateIssue logs a new issue for a wiki page.
CreateIssue(ctx context.Context, issue *types.WikiPageIssue) (*types.WikiPageIssue, error)
// ListIssues retrieves issues for a knowledge base, optionally filtered by slug and status.
ListIssues(ctx context.Context, kbID string, slug string, status string) ([]*types.WikiPageIssue, error)
// UpdateIssueStatus updates the status of an issue (e.g. pending -> resolved/ignored).
UpdateIssueStatus(ctx context.Context, issueID string, status string) error
}
// WikiPageRepository defines the wiki page data persistence interface.
type WikiPageRepository interface {
// Create inserts a new wiki page record.
Create(ctx context.Context, page *types.WikiPage) error
// Update rewrites a wiki page record with optimistic locking and
// unconditionally increments `version`. Callers are responsible for
// deciding whether the edit is user-visible — the service layer uses
// UpdateMeta for bookkeeping-only writes instead.
Update(ctx context.Context, page *types.WikiPage) error
// UpdateMeta updates bookkeeping / provenance fields (in/out links,
// status, source_refs, chunk_refs, page_metadata, updated_at) without
// touching `version`. Safe for link maintenance, re-ingest with an
// unchanged body, and status-only transitions.
UpdateMeta(ctx context.Context, page *types.WikiPage) error
// UpdateAutoLinkedContent rewrites `content`, `out_links` and
// `updated_at` in place while leaving `version` untouched. Intended for
// machine-only link markup changes (cross-link injection / dead-link
// cleanup) so the first-ingest page doesn't jump to v2 just because the
// post-processor added a `[[...]]` wrapper.
UpdateAutoLinkedContent(ctx context.Context, page *types.WikiPage) error
// GetByID retrieves a wiki page by its unique ID.
GetByID(ctx context.Context, id string) (*types.WikiPage, error)
// GetBySlug retrieves a wiki page by slug within a knowledge base.
GetBySlug(ctx context.Context, kbID string, slug string) (*types.WikiPage, error)
// List retrieves wiki pages with filtering and pagination.
List(ctx context.Context, req *types.WikiPageListRequest) ([]*types.WikiPage, int64, error)
// ListByType retrieves all wiki pages of a given type within a knowledge base.
ListByType(ctx context.Context, kbID string, pageType string) ([]*types.WikiPage, error)
// ListByTypeLight returns a paginated window of lightweight entries
// (slug/title/summary only) for the given page_type plus the total
// non-archived count. Used by the structured index API so reads do not
// have to materialize TEXT content for every wiki_pages row.
ListByTypeLight(ctx context.Context, kbID string, pageType string, limit int, offset int) ([]types.WikiIndexEntry, int64, error)
// ListBySourceRef retrieves all wiki pages that reference a given source knowledge ID.
ListBySourceRef(ctx context.Context, kbID string, sourceKnowledgeID string) ([]*types.WikiPage, error)
// ListSlugsBySourceRef returns just the slugs of pages that reference
// the given knowledge id. Same predicate as ListBySourceRef but
// projected to a single column for the wiki ingest pipeline's
// "before" snapshot path.
ListSlugsBySourceRef(ctx context.Context, kbID string, sourceKnowledgeID string) ([]string, error)
// ListBySlugs returns lightweight projections (slug/title/page_type/
// status/aliases/out_links) for the given slugs in one IN query.
// Used by the lazy fetcher path in wiki ingest's Map/Reduce phases.
ListBySlugs(ctx context.Context, kbID string, slugs []string) (map[string]*types.WikiPageLite, error)
// ListSummariesByKnowledgeIDs returns summary-page content keyed by
// the knowledge id that authored it. Used by the retract branch of
// reduceSlugUpdates for "what was this doc's contribution?" framing.
ListSummariesByKnowledgeIDs(ctx context.Context, kbID string, kids []string) (map[string]string, error)
// ExistsSlugs reports which of the given slugs are live (non-
// archived). Used by cleanDeadLinks to validate out-link targets
// without loading the referenced pages' content.
ExistsSlugs(ctx context.Context, kbID string, slugs []string) (map[string]bool, error)
// ListAllSlugs returns every non-archived slug in the KB. Used by
// lint to compute the live-slug set without paying for ListAll's
// full row materialization.
ListAllSlugs(ctx context.Context, kbID string) ([]string, error)
// ListPagesCursor returns up to `limit` pages ordered by id ASC,
// paginated by an opaque numeric cursor. Used by lint to stream
// the KB without loading every page at once.
ListPagesCursor(ctx context.Context, kbID string, cursor string, limit int) ([]*types.WikiPage, string, error)
// ListByTypeRecent returns the most-recently-updated pages of the
// given type, projected to slug/title/summary, capped at `limit`.
// Used by rebuildIndexPage's first-time generation path so the
// LLM prompt size is bounded on large KBs.
ListByTypeRecent(ctx context.Context, kbID string, pageType string, limit int) ([]types.WikiIndexEntry, error)
// FindSimilarPages returns the top-k pages whose lowercase title
// is most similar to the query under pg_trgm. `pageTypes` empty
// defaults to entity+concept. Used by the dedup pre-filter to
// surface candidate merge targets server-side.
FindSimilarPages(ctx context.Context, kbID string, query string, pageTypes []string, limit int) ([]*types.WikiPageLite, error)
// FindPagesByNormalizedTitle returns non-archived pages of pageType whose
// whitespace-stripped, lowercased title equals identity.
FindPagesByNormalizedTitle(ctx context.Context, kbID, pageType, identity string) ([]*types.WikiPageLite, error)
// FindPagesByNormalizedTitles is the batched form of
// FindPagesByNormalizedTitle.
FindPagesByNormalizedTitles(ctx context.Context, kbID, pageType string, identities []string) ([]*types.WikiPageLite, error)
// ListDistinctCategoryPaths returns the materialized paths of existing
// wiki folders (split into segments), capped at maxPaths. Used by the
// wiki ingest taxonomy planner as the pool of folders to reuse.
ListDistinctCategoryPaths(ctx context.Context, kbID string, maxPaths int) ([][]string, error)
// --- Folder tree (wiki_folders) ---
// CreateFolder inserts a new directory node.
CreateFolder(ctx context.Context, folder *types.WikiFolder) error
// GetFolderByID retrieves a folder by id within a knowledge base.
GetFolderByID(ctx context.Context, kbID string, id string) (*types.WikiFolder, error)
// GetChildFolderByName returns the live child folder of parentID with the
// given name, or ErrWikiFolderNotFound. Used by find-or-create.
GetChildFolderByName(ctx context.Context, kbID string, parentID string, name string) (*types.WikiFolder, error)
// ListChildFolders returns the direct child folders of parentID, ordered
// by sort_order then name.
ListChildFolders(ctx context.Context, kbID string, parentID string) ([]*types.WikiFolder, error)
// ListAllFolders returns every folder in the knowledge base.
ListAllFolders(ctx context.Context, kbID string) ([]*types.WikiFolder, error)
// UpdateFolder rewrites a folder's mutable fields (name, parent_id, path,
// depth, sort_order, updated_at).
UpdateFolder(ctx context.Context, folder *types.WikiFolder) error
// DeleteFolder soft-deletes a folder by id.
DeleteFolder(ctx context.Context, kbID string, id string) error
// CountPagesInFolder returns the number of live (non-archived) pages
// directly in the folder (folder_id = id).
CountPagesInFolder(ctx context.Context, kbID string, folderID string) (int64, error)
// CountPagesByFolder returns live (non-archived) page counts grouped by
// folder_id. When pageTypes is non-empty the count is restricted to those
// page types; otherwise all types are counted. Pages at the wiki root carry
// the empty-string key.
CountPagesByFolder(ctx context.Context, kbID string, pageTypes []string) (map[string]int64, error)
// ListPagesByFolderIDs returns all pages whose folder_id is in the set.
// Used to recompute cached paths when a folder subtree is moved/renamed.
ListPagesByFolderIDs(ctx context.Context, kbID string, folderIDs []string) ([]*types.WikiPage, error)
// ListAll retrieves all non-archived wiki pages in a knowledge base (for link rebuilding, graph generation).
ListAll(ctx context.Context, kbID string) ([]*types.WikiPage, error)
// ListRecentForSuggestions returns recent user-visible wiki pages under the given
// knowledge bases, used to produce fallback suggested questions for Wiki-only KBs
// that do not have AI-generated document questions or recommended FAQ entries.
// Excludes the index page and archived pages. Returns up to `limit` rows sorted
// by updated_at descending.
ListRecentForSuggestions(ctx context.Context, tenantID uint64, kbIDs []string, limit int) ([]*types.WikiPage, error)
// Delete soft-deletes a wiki page by knowledge base ID and slug.
Delete(ctx context.Context, kbID string, slug string) error
// DeleteByID soft-deletes a wiki page by ID.
DeleteByID(ctx context.Context, id string) error
// Search performs full-text search on wiki pages within a knowledge base.
Search(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error)
// CountByType returns page counts grouped by type for a knowledge base.
CountByType(ctx context.Context, kbID string) (map[string]int64, error)
// CountOrphans returns the number of pages with no inbound links.
CountOrphans(ctx context.Context, kbID string) (int64, error)
// UpdateWithRevision snapshots the version being superseded and applies
// the page update in a single transaction, so history can never contain
// a snapshot of a version that is still current. Inserting an already-
// present (page_id, version) pair is a silent no-op.
UpdateWithRevision(ctx context.Context, page *types.WikiPage, rev *types.WikiPageRevision) error
// ListRevisions returns snapshots for a page newest-first (content
// column omitted) plus the total snapshot count.
ListRevisions(ctx context.Context, kbID string, pageID string, limit int, offset int) ([]*types.WikiPageRevision, int64, error)
// GetRevision returns one snapshot with content.
GetRevision(ctx context.Context, kbID string, pageID string, version int) (*types.WikiPageRevision, error)
// PruneRevisions bounds per-page history storage using the two-tier
// retention described by the request.
PruneRevisions(ctx context.Context, req types.WikiRevisionPruneRequest) error
// DeleteRevisionsByPage hard-deletes a page's entire snapshot history.
DeleteRevisionsByPage(ctx context.Context, pageID string) error
// CreateIssue inserts a new wiki page issue record.
CreateIssue(ctx context.Context, issue *types.WikiPageIssue) error
// ListIssues retrieves issues with optional filtering by slug and status.
ListIssues(ctx context.Context, kbID string, slug string, status string) ([]*types.WikiPageIssue, error)
// UpdateIssueStatus updates an issue's status.
UpdateIssueStatus(ctx context.Context, issueID string, status string) error
}
+85
View File
@@ -0,0 +1,85 @@
package types
import (
"database/sql/driver"
"encoding/json"
"errors"
)
// JSON is a custom type that wraps json.RawMessage.
// Used for storing JSON data in the database.
type JSON json.RawMessage
// Scan implements the sql.Scanner interface.
func (j *JSON) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion to []byte or string failed")
}
if len(bytes) == 0 {
*j = nil
return nil
}
result := json.RawMessage{}
err := json.Unmarshal(bytes, &result)
*j = JSON(result)
return err
}
// Value implements the driver.Valuer interface.
func (j JSON) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return json.RawMessage(j).MarshalJSON()
}
// MarshalJSON implements the json.Marshaler interface.
func (j JSON) MarshalJSON() ([]byte, error) {
if len(j) == 0 {
return []byte("null"), nil
}
return j, nil
}
// UnmarshalJSON implements the json.Unmarshaler interface.
func (j *JSON) UnmarshalJSON(data []byte) error {
if j == nil {
return errors.New("JSON: UnmarshalJSON on nil pointer")
}
copied := make([]byte, len(data))
copy(copied, data)
*j = JSON(copied)
return nil
}
// ToString converts JSON to a string.
func (j JSON) ToString() string {
if len(j) == 0 {
return "{}"
}
return string(j)
}
// Map converts JSON to a map.
func (j JSON) Map() (map[string]interface{}, error) {
if len(j) == 0 {
return map[string]interface{}{}, nil
}
var m map[string]interface{}
err := json.Unmarshal(j, &m)
return m, err
}
+50
View File
@@ -0,0 +1,50 @@
package types
import (
"database/sql/driver"
"encoding/json"
"errors"
)
// JSONMap is a map[string]any that round-trips through Postgres JSONB
// columns via GORM. The standard library's database/sql doesn't know how
// to scan JSONB into a typed map, so we hand-roll Value() / Scan().
//
// Lives here (not in /utils) because the only callers are types whose
// schemas already declare JSONB columns — keeping the helper next to its
// users avoids a circular import in any package that already pulls in
// /types.
type JSONMap map[string]any
// Value implements driver.Valuer. Returns nil when the map is nil so
// JSONB columns store SQL NULL instead of "null".
func (m JSONMap) Value() (driver.Value, error) {
if m == nil {
return nil, nil
}
return json.Marshal(m)
}
// Scan implements sql.Scanner. Accepts both []byte (real JSONB) and
// string (some drivers under sqlmock); anything else is a programming
// error.
func (m *JSONMap) Scan(src any) error {
if src == nil {
*m = nil
return nil
}
var raw []byte
switch v := src.(type) {
case []byte:
raw = v
case string:
raw = []byte(v)
default:
return errors.New("JSONMap.Scan: unsupported source type")
}
if len(raw) == 0 {
*m = nil
return nil
}
return json.Unmarshal(raw, m)
}
+37
View File
@@ -0,0 +1,37 @@
package types
import (
"encoding/json"
"testing"
)
func TestJSONUnmarshalCopiesInput(t *testing.T) {
type wrapper struct {
Value JSON `json:"value"`
}
input := []byte(`{"value":{"generated_questions":[{"id":"q1","question":"今晚吃啥"}]}}`)
var got wrapper
if err := json.Unmarshal(input, &got); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
// Simulate decoder buffer reuse/caller mutation after UnmarshalJSON returns.
for i := range input {
input[i] = 'x'
}
if !json.Valid(got.Value) {
t.Fatalf("stored JSON became invalid after input mutation: %q", string(got.Value))
}
marshaled, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
if len(marshaled) == 0 {
t.Fatal("expected marshaled output")
}
}

Some files were not shown because too many files have changed in this diff Show More