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
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:
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 != ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package types
|
||||
|
||||
// CleanupFunc represents the resource cleanup function
|
||||
type CleanupFunc func() error
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:"
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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: ¤tRevision},
|
||||
},
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user