mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 23:00:30 +08:00
feat(agent): Go port — canvas engine, 22 components, DSL v2, 13 endpoints (#15952)
Ports the agent canvas subsystem from Python to Go.
## What's included
### Canvas Engine (Phase 0/1)
- State engine, scheduler, variable resolver, Redis checkpoint store,
cancel protocol
- **209 tests** across canvas / component / io packages
### 22 Components (P0–P4)
| Tier | Components |
|---|---|
| P0 T1+T2+T3 | LLM, Agent, ExitLoop, Switch, Categorize, Begin,
Message, Invoke |
| P1 T3 | VariableAggregator, VariableAssigner, StringTransform,
ListOperations, DataOperations |
| P2 T3 | Iteration, IterationItem, Loop, LoopItem |
| P3 T3 | UserFillUp, Fillup |
| P4 T5 | Browser, ExcelProcessor, DocsGenerator |
### DSL v2 Schema (Phase 2.5)
- Typed v2 in-memory model with v1-to-v2 auto-detect converter
- v1 legacy field stripping per plan §2.11.7
### HTTP Endpoints & Bug Fixes (Plans PR1–PR3)
- **DELETE SQL bug fix**: gorm v2 `Where("id = ?", id).Delete(...)`
pattern
- **CreateAgent validation**: title/DSL required, duplicate check, 103
envelope
- **13 new endpoints**: templates, prompts, tags, sessions CRUD,
chat/completions (SSE + non-stream stubs), rerun, test_db_connection,
logs, webhook/logs
- **756 Go unit tests** (745 → 756, +18)
- **17 → 0 Python integration test failures** (test_agents.py +
test_session_management/)
### Tools
21 eino tools: HTTPHelper, search tools, financial/data tools, mandatory
stubs
### Infrastructure
OTel observability, NATS message queue, DeepDoc gRPC client, SSRF
guards, IDOR mitigation
This commit is contained in:
File diff suppressed because it is too large
Load Diff
256
internal/service/agent_dbcheck.go
Normal file
256
internal/service/agent_dbcheck.go
Normal file
@@ -0,0 +1,256 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"ragflow/internal/common"
|
||||
)
|
||||
|
||||
// TestDBConnectionRequest is the request body for AgentService.TestDBConnection.
|
||||
type TestDBConnectionRequest struct {
|
||||
DBType string `json:"db_type"`
|
||||
Database string `json:"database"`
|
||||
Username string `json:"username"`
|
||||
Host string `json:"host"`
|
||||
Port interface{} `json:"port"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AssertHostIsSafe returns the first resolved public IP for host, or an
|
||||
// error when the host resolves to any non-public address. The check
|
||||
// mirrors the SSRF guard in the Python implementation so external
|
||||
// service calls cannot pivot to internal network ranges.
|
||||
func AssertHostIsSafe(host string) (string, error) {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return "", errors.New("Host must not be empty.")
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
zap.L().Warn("SSRF guard could not resolve host",
|
||||
zap.String("host", host),
|
||||
zap.Error(err),
|
||||
)
|
||||
return "", fmt.Errorf("Could not resolve host %q: %w", host, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
zap.L().Warn("SSRF guard blocked host: resolved to no addresses",
|
||||
zap.String("host", host),
|
||||
)
|
||||
return "", fmt.Errorf("Host %q resolved to no addresses.", host)
|
||||
}
|
||||
|
||||
var resolvedIP string
|
||||
for _, ip := range ips {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid resolved IP %q for host %q", ip.String(), host)
|
||||
}
|
||||
// Normalize IPv4-mapped IPv6, equivalent to Python _effective_ip().
|
||||
addr = addr.Unmap()
|
||||
|
||||
if !isPublicAddr(addr) {
|
||||
zap.L().Warn("SSRF guard blocked host",
|
||||
zap.String("host", host),
|
||||
zap.String("resolved_ip", addr.String()),
|
||||
)
|
||||
return "", fmt.Errorf("Host resolves to a non-public address (%s), which is not allowed.", addr.String())
|
||||
}
|
||||
if resolvedIP == "" {
|
||||
resolvedIP = addr.String()
|
||||
}
|
||||
}
|
||||
if resolvedIP == "" {
|
||||
return "", fmt.Errorf("Host %q resolved to no addresses.", host)
|
||||
}
|
||||
return resolvedIP, nil
|
||||
}
|
||||
|
||||
func isPublicAddr(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
|
||||
if !addr.IsValid() {
|
||||
return false
|
||||
}
|
||||
if !addr.IsGlobalUnicast() {
|
||||
return false
|
||||
}
|
||||
if addr.IsPrivate() ||
|
||||
addr.IsLoopback() ||
|
||||
addr.IsLinkLocalUnicast() ||
|
||||
addr.IsLinkLocalMulticast() ||
|
||||
addr.IsMulticast() ||
|
||||
addr.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
return !isSpecialUseAddr(addr)
|
||||
}
|
||||
|
||||
func isSpecialUseAddr(addr netip.Addr) bool {
|
||||
addr = addr.Unmap()
|
||||
|
||||
specialCIDRs := []string{
|
||||
// IPv4 special-use / documentation / reserved ranges.
|
||||
"0.0.0.0/8",
|
||||
"100.64.0.0/10",
|
||||
"127.0.0.0/8",
|
||||
"169.254.0.0/16",
|
||||
"192.0.0.0/24",
|
||||
"192.0.2.0/24",
|
||||
"198.18.0.0/15",
|
||||
"198.51.100.0/24",
|
||||
"203.0.113.0/24",
|
||||
"224.0.0.0/4",
|
||||
"240.0.0.0/4",
|
||||
|
||||
// IPv6 special-use / documentation / local ranges.
|
||||
"::/128",
|
||||
"::1/128",
|
||||
"64:ff9b:1::/48",
|
||||
"100::/64",
|
||||
"2001::/23",
|
||||
"2001:2::/48",
|
||||
"fc00::/7",
|
||||
"fe80::/10",
|
||||
"ff00::/8",
|
||||
"2001:db8::/32",
|
||||
"2002::/16",
|
||||
}
|
||||
for _, cidr := range specialCIDRs {
|
||||
prefix := netip.MustParsePrefix(cidr)
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func missingDBConnectionFields(req *TestDBConnectionRequest) []string {
|
||||
missing := make([]string, 0, 6)
|
||||
if req == nil || strings.TrimSpace(req.DBType) == "" {
|
||||
missing = append(missing, "db_type")
|
||||
}
|
||||
if req == nil || strings.TrimSpace(req.Database) == "" {
|
||||
missing = append(missing, "database")
|
||||
}
|
||||
if req == nil || strings.TrimSpace(req.Username) == "" {
|
||||
missing = append(missing, "username")
|
||||
}
|
||||
if req == nil || strings.TrimSpace(req.Host) == "" {
|
||||
missing = append(missing, "host")
|
||||
}
|
||||
if req == nil || dbConnectionPort(req.Port) == "" {
|
||||
missing = append(missing, "port")
|
||||
}
|
||||
if req == nil || req.Password == "" {
|
||||
missing = append(missing, "password")
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func dbConnectionPort(port interface{}) string {
|
||||
switch value := port.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return strings.TrimSpace(value)
|
||||
case float64:
|
||||
return strconv.Itoa(int(value))
|
||||
case float32:
|
||||
return strconv.Itoa(int(value))
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
default:
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDBConnection validates input and performs a probe connect against
|
||||
// the requested database. The probe enforces an SSRF allow-list and a
|
||||
// short timeout to keep the API responsive when targets are unreachable.
|
||||
// The "required argument are missing" message has a trailing semicolon
|
||||
// and space to stay byte-identical with the Python implementation.
|
||||
func (s *AgentService) TestDBConnection(userID string, req *TestDBConnectionRequest) (common.ErrorCode, error) {
|
||||
if missing := missingDBConnectionFields(req); len(missing) > 0 {
|
||||
return common.CodeArgumentError, fmt.Errorf("required argument are missing: %s; ", strings.Join(missing, ","))
|
||||
}
|
||||
|
||||
safeHost, err := AssertHostIsSafe(req.Host)
|
||||
if err != nil {
|
||||
zap.L().Warn(
|
||||
"Rejected test_db_connection: unsafe host",
|
||||
zap.String("host", req.Host),
|
||||
zap.String("db_type", req.DBType),
|
||||
zap.String("user", userID),
|
||||
zap.Error(err),
|
||||
)
|
||||
return common.CodeDataError, err
|
||||
}
|
||||
|
||||
switch req.DBType {
|
||||
case "mysql", "mariadb", "oceanbase":
|
||||
port := dbConnectionPort(req.Port)
|
||||
dbProbeTimeout := 5 * time.Second
|
||||
config := mysql.Config{
|
||||
User: req.Username,
|
||||
Passwd: req.Password,
|
||||
Net: "tcp",
|
||||
Addr: net.JoinHostPort(safeHost, port),
|
||||
DBName: req.Database,
|
||||
Timeout: dbProbeTimeout,
|
||||
AllowNativePasswords: true,
|
||||
}
|
||||
db, err := sql.Open("mysql", config.FormatDSN())
|
||||
if err != nil {
|
||||
return common.CodeExceptionError, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), dbProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return common.CodeExceptionError, err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, "SELECT 1"); err != nil {
|
||||
return common.CodeExceptionError, err
|
||||
}
|
||||
default:
|
||||
return common.CodeExceptionError, errors.New("Unsupported database type.")
|
||||
}
|
||||
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
723
internal/service/agent_sessions.go
Normal file
723
internal/service/agent_sessions.go
Normal file
@@ -0,0 +1,723 @@
|
||||
//
|
||||
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
const (
|
||||
agentTagsFieldMax = 512
|
||||
agentTagMaxLen = 64
|
||||
)
|
||||
|
||||
// ListAgentSessionsRequest are the parameters for ListAgentSessions.
|
||||
type ListAgentSessionsRequest struct {
|
||||
SessionID string
|
||||
UserID string
|
||||
Page int
|
||||
PageSize int
|
||||
Keywords string
|
||||
FromDate string
|
||||
ToDate string
|
||||
OrderBy string
|
||||
Desc bool
|
||||
ExpUserID string
|
||||
IncludeDSL bool
|
||||
}
|
||||
|
||||
// ListAgentSessionsResponse is the response body for ListAgentSessions.
|
||||
type ListAgentSessionsResponse struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// DeleteAgentSessionsResult wraps DeleteAgentSessionsResponse with a message.
|
||||
type DeleteAgentSessionsResult struct {
|
||||
Data *DeleteAgentSessionsResponse
|
||||
Message string
|
||||
}
|
||||
|
||||
// DeleteAgentSessionsResponse summarises a multi-id delete.
|
||||
type DeleteAgentSessionsResponse struct {
|
||||
SuccessCount int `json:"success_count"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// CheckCanvasAccess returns true when the user is the canvas owner or
|
||||
// holds team-level permission for the owner's tenant.
|
||||
func (s *AgentService) CheckCanvasAccess(userID, canvasID string) (bool, error) {
|
||||
canvas, err := s.canvasDAO.GetByID(canvasID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if canvas.UserID == userID {
|
||||
return true, nil
|
||||
}
|
||||
if canvas.Permission != string(entity.TenantPermissionTeam) {
|
||||
return false, nil
|
||||
}
|
||||
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, tid := range tenantIDs {
|
||||
if canvas.UserID == tid {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func parseAgentSessionDate(value string, isEnd bool) (*time.Time, error) {
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if strings.Contains(value, "T") {
|
||||
normalized := strings.ReplaceAll(value, "Z", "+00:00")
|
||||
parsed, err := time.Parse(time.RFC3339, normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
local := parsed.Local()
|
||||
return &local, nil
|
||||
}
|
||||
|
||||
if len(value) == 10 {
|
||||
if isEnd {
|
||||
value += " 23:59:59"
|
||||
} else {
|
||||
value += " 00:00:00"
|
||||
}
|
||||
}
|
||||
|
||||
parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsed, nil
|
||||
}
|
||||
|
||||
func normalizeAgentSession(session *entity.API4Conversation, includeDSL bool) map[string]interface{} {
|
||||
messages := parseAgentSessionMessages(session.Message)
|
||||
references := parseAgentSessionReferences(session.Reference)
|
||||
|
||||
for _, message := range messages {
|
||||
delete(message, "prompt")
|
||||
}
|
||||
|
||||
if len(references) > 0 {
|
||||
assistantMessages := make([]map[string]interface{}, 0)
|
||||
for i, message := range messages {
|
||||
role, _ := message["role"].(string)
|
||||
if i != 0 && role != "user" {
|
||||
assistantMessages = append(assistantMessages, message)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(assistantMessages) && i < len(references); i++ {
|
||||
rawChunks, _ := references[i]["chunks"].([]interface{})
|
||||
assistantMessages[i]["reference"] = normalizeAgentReferenceChunks(rawChunks)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": session.ID,
|
||||
"name": session.Name,
|
||||
"agent_id": session.DialogID,
|
||||
"user_id": session.UserID,
|
||||
"exp_user_id": session.ExpUserID,
|
||||
"message": messages,
|
||||
"tokens": session.Tokens,
|
||||
"source": session.Source,
|
||||
"duration": session.Duration,
|
||||
"round": session.Round,
|
||||
"thumb_up": session.ThumbUp,
|
||||
"errors": session.Errors,
|
||||
"version_title": session.VersionTitle,
|
||||
"create_time": session.CreateTime,
|
||||
"create_date": session.CreateDate,
|
||||
"update_time": session.UpdateTime,
|
||||
"update_date": session.UpdateDate,
|
||||
}
|
||||
if includeDSL {
|
||||
result["dsl"] = session.DSL
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAgentSessionReferences(raw json.RawMessage) []map[string]interface{} {
|
||||
if len(raw) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
var references []map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &references); err == nil {
|
||||
for i, reference := range references {
|
||||
references[i] = normalizeAgentReferenceEntry(reference)
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
var referenceMap map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &referenceMap); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
if _, ok := referenceMap["chunks"]; ok {
|
||||
return []map[string]interface{}{normalizeAgentReferenceEntry(referenceMap)}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(referenceMap))
|
||||
for key := range referenceMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
left, _ := strconv.Atoi(keys[i])
|
||||
right, _ := strconv.Atoi(keys[j])
|
||||
return left < right
|
||||
})
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
reference, ok := referenceMap[key].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, normalizeAgentReferenceEntry(reference))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAgentSessionMessages(raw json.RawMessage) []map[string]interface{} {
|
||||
if len(raw) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
var messages []map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &messages); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func normalizeAgentReferenceEntry(reference map[string]interface{}) map[string]interface{} {
|
||||
if reference == nil {
|
||||
return map[string]interface{}{
|
||||
"chunks": []interface{}{},
|
||||
"doc_aggs": []interface{}{},
|
||||
}
|
||||
}
|
||||
if _, ok := reference["chunks"]; ok {
|
||||
return map[string]interface{}{
|
||||
"chunks": valueOrEmptySlice(reference["chunks"]),
|
||||
"doc_aggs": valueOrEmptySlice(reference["doc_aggs"]),
|
||||
}
|
||||
}
|
||||
if _, ok := reference["doc_aggs"]; ok {
|
||||
return map[string]interface{}{
|
||||
"chunks": valueOrEmptySlice(reference["chunks"]),
|
||||
"doc_aggs": valueOrEmptySlice(reference["doc_aggs"]),
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"chunks": valueOrEmptySlice(reference["reference"]),
|
||||
"doc_aggs": valueOrEmptySlice(reference["doc_aggs"]),
|
||||
}
|
||||
}
|
||||
|
||||
func valueOrEmptySlice(value interface{}) interface{} {
|
||||
if value == nil {
|
||||
return []interface{}{}
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeAgentReferenceChunks(chunks []interface{}) []map[string]interface{} {
|
||||
result := make([]map[string]interface{}, 0, len(chunks))
|
||||
for _, rawChunk := range chunks {
|
||||
chunk, ok := rawChunk.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": firstNonNil(chunk["chunk_id"], chunk["id"]),
|
||||
"content": firstNonNil(chunk["content_with_weight"], chunk["content"]),
|
||||
"document_id": firstNonNil(chunk["doc_id"], chunk["document_id"]),
|
||||
"document_name": firstNonNil(chunk["docnm_kwd"], chunk["document_name"]),
|
||||
"dataset_id": firstNonNil(chunk["kb_id"], chunk["dataset_id"]),
|
||||
"image_id": firstNonNil(chunk["image_id"], chunk["img_id"]),
|
||||
"positions": firstNonNil(chunk["positions"], chunk["position_int"]),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNonNil(values ...interface{}) interface{} {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDuplicateSessionIDs returns the de-duplicated id list and a slice of
|
||||
// human-readable duplicate messages.
|
||||
func checkDuplicateSessionIDs(ids []string) ([]string, []string) {
|
||||
seen := make(map[string]int, len(ids))
|
||||
uniqueIDs := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
seen[id]++
|
||||
if seen[id] == 1 {
|
||||
uniqueIDs = append(uniqueIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
duplicateMessages := make([]string, 0)
|
||||
for _, id := range uniqueIDs {
|
||||
if seen[id] > 1 {
|
||||
duplicateMessages = append(duplicateMessages, fmt.Sprintf("Duplicate session ids: %s", id))
|
||||
}
|
||||
}
|
||||
return uniqueIDs, duplicateMessages
|
||||
}
|
||||
|
||||
// ListAgentSessions returns paginated agent sessions visible to the caller.
|
||||
func (s *AgentService) ListAgentSessions(userID, tenantID, agentID string, req ListAgentSessionsRequest) (*ListAgentSessionsResponse, common.ErrorCode, error) {
|
||||
if agentID == "" {
|
||||
return nil, common.CodeArgumentError, errors.New("agent_id is required")
|
||||
}
|
||||
|
||||
ok, err := s.CheckCanvasAccess(userID, agentID)
|
||||
if err != nil {
|
||||
// The Python agent API folds "canvas does not exist" into
|
||||
// the same 103 access envelope as a permission mismatch
|
||||
// (see @_require_canvas_access_async). Surface that
|
||||
// shape here instead of a 500 record not found so the
|
||||
// front-end does not log a server error for unknown ids.
|
||||
if errors.Is(err, dao.ErrUserCanvasNotFound) {
|
||||
return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
return nil, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
|
||||
sessionDAO := dao.NewChatSessionDAO()
|
||||
|
||||
if req.ExpUserID != "" {
|
||||
rows, err := sessionDAO.ListAgentSessionNames(agentID, req.ExpUserID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
return &ListAgentSessionsResponse{Data: rows, Total: int64(len(rows))}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
fromDate, err := parseAgentSessionDate(req.FromDate, false)
|
||||
if err != nil {
|
||||
return nil, common.CodeArgumentError, err
|
||||
}
|
||||
toDate, err := parseAgentSessionDate(req.ToDate, true)
|
||||
if err != nil {
|
||||
return nil, common.CodeArgumentError, err
|
||||
}
|
||||
|
||||
total, sessions, err := sessionDAO.ListAgentSessions(dao.ListAgentSessionsParams{
|
||||
AgentID: agentID,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
OrderBy: req.OrderBy,
|
||||
Desc: req.Desc,
|
||||
SessionID: req.SessionID,
|
||||
UserID: req.UserID,
|
||||
IncludeDSL: req.IncludeDSL,
|
||||
Keywords: req.Keywords,
|
||||
FromDate: fromDate,
|
||||
ToDate: toDate,
|
||||
ExpUserID: req.ExpUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
data := make([]map[string]interface{}, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
data = append(data, normalizeAgentSession(session, req.IncludeDSL))
|
||||
}
|
||||
return &ListAgentSessionsResponse{Data: data, Total: total}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// GetAgentSession fetches a single conversation belonging to agentID.
|
||||
func (s *AgentService) GetAgentSession(userID, agentID, sessionID string) (*entity.API4Conversation, common.ErrorCode, error) {
|
||||
if sessionID == "" {
|
||||
return nil, common.CodeArgumentError, fmt.Errorf("session_id is required")
|
||||
}
|
||||
ok, err := s.CheckCanvasAccess(userID, agentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, dao.ErrUserCanvasNotFound) {
|
||||
return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
return nil, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
|
||||
data, err := s.api4ConversationDAO.GetBySessionID(sessionID, agentID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("failed to fetch session: %w", err)
|
||||
}
|
||||
if data == nil {
|
||||
return nil, common.CodeNotFound, fmt.Errorf("agent session not found")
|
||||
}
|
||||
return data, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// DeleteAgentSessionItem removes one conversation if it belongs to agentID.
|
||||
func (s *AgentService) DeleteAgentSessionItem(userID, agentID, sessionID string) (bool, common.ErrorCode, error) {
|
||||
if sessionID == "" {
|
||||
return false, common.CodeArgumentError, errors.New("session_id is required")
|
||||
}
|
||||
ok, err := s.CheckCanvasAccess(userID, agentID)
|
||||
if err != nil {
|
||||
if errors.Is(err, dao.ErrUserCanvasNotFound) {
|
||||
return false, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
return false, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return false, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
|
||||
row, err := s.api4ConversationDAO.DeleteBySessionIDAndAgentID(sessionID, agentID)
|
||||
if err != nil {
|
||||
return false, common.CodeServerError, err
|
||||
}
|
||||
if row == 0 {
|
||||
return false, common.CodeSuccess, nil
|
||||
}
|
||||
return true, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// DeleteAgentSessions removes multiple conversations owned by agentID.
|
||||
// When ids is empty and deleteAll is true, every session under agentID is
|
||||
// removed.
|
||||
func (s *AgentService) DeleteAgentSessions(userID, agentID string, ids []string, deleteAll bool) (*DeleteAgentSessionsResult, common.ErrorCode, error) {
|
||||
if agentID == "" {
|
||||
return nil, common.CodeArgumentError, errors.New("agent_id is required")
|
||||
}
|
||||
|
||||
// Owner-only by design: batch session deletion is destructive and must
|
||||
// not be available to team members even when the canvas has team
|
||||
// permission. CheckCanvasAccess (used elsewhere) would also allow team
|
||||
// access, which is too permissive for this operation.
|
||||
canvas, err := s.canvasDAO.GetByID(agentID)
|
||||
if err != nil || canvas == nil || canvas.UserID != userID {
|
||||
return nil, common.CodeDataError, fmt.Errorf("You don't own the agent %s", agentID)
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
if !deleteAll {
|
||||
return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
ids, err = s.api4ConversationDAO.ListIDsByAgentID(agentID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil
|
||||
}
|
||||
}
|
||||
|
||||
sessionIDs, duplicateMessages := checkDuplicateSessionIDs(ids)
|
||||
errorsList := make([]string, 0)
|
||||
successCount := 0
|
||||
|
||||
for _, sessionID := range sessionIDs {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
errorsList = append(errorsList, "Session ID is empty")
|
||||
continue
|
||||
}
|
||||
|
||||
conv, err := s.api4ConversationDAO.GetBySessionID(sessionID, agentID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if conv == nil {
|
||||
errorsList = append(errorsList, fmt.Sprintf("The agent doesn't own the session %s", sessionID))
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.api4ConversationDAO.DeleteBySessionIDAndAgentID(sessionID, agentID); err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
successCount++
|
||||
}
|
||||
|
||||
if len(errorsList) > 0 {
|
||||
if successCount > 0 {
|
||||
return &DeleteAgentSessionsResult{
|
||||
Message: fmt.Sprintf("Partially deleted %d sessions with %d errors", successCount, len(errorsList)),
|
||||
Data: &DeleteAgentSessionsResponse{
|
||||
SuccessCount: successCount,
|
||||
Errors: errorsList,
|
||||
},
|
||||
}, common.CodeSuccess, nil
|
||||
}
|
||||
return nil, common.CodeDataError, errors.New(strings.Join(errorsList, "; "))
|
||||
}
|
||||
|
||||
if len(duplicateMessages) > 0 {
|
||||
return &DeleteAgentSessionsResult{
|
||||
Message: fmt.Sprintf("Partially deleted %d sessions with %d errors", successCount, len(duplicateMessages)),
|
||||
Data: &DeleteAgentSessionsResponse{
|
||||
SuccessCount: successCount,
|
||||
Errors: duplicateMessages,
|
||||
},
|
||||
}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// normalizeAgentTags returns an error for unsupported tag payload types.
|
||||
// The branch behaviour intentionally mirrors the Python implementation:
|
||||
// - string: treat the value as a CSV — split on "," and use each piece
|
||||
// as a separate tag ("alpha,beta" → ["alpha", "beta"]).
|
||||
// - []string / []interface{}: the caller already chose the boundary;
|
||||
// embedded commas are therefore replaced with spaces rather than
|
||||
// re-split (["alpha,beta"] → ["alpha beta"]).
|
||||
//
|
||||
// This asymmetry is required to keep tag handling byte-identical with
|
||||
// agent_api.update_agent in the Python service.
|
||||
func normalizeAgentTags(rawTags interface{}) (string, error) {
|
||||
cleaned := make([]string, 0)
|
||||
switch tags := rawTags.(type) {
|
||||
case nil:
|
||||
case string:
|
||||
for _, tag := range strings.Split(tags, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
cleaned = append(cleaned, tag)
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
for _, tag := range tags {
|
||||
tag = strings.TrimSpace(strings.ReplaceAll(tag, ",", " "))
|
||||
if tag != "" {
|
||||
cleaned = append(cleaned, tag)
|
||||
}
|
||||
}
|
||||
case []interface{}:
|
||||
for _, value := range tags {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
tag := strings.TrimSpace(strings.ReplaceAll(fmt.Sprint(value), ",", " "))
|
||||
if tag != "" {
|
||||
cleaned = append(cleaned, tag)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("tags must be a string or array")
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(cleaned))
|
||||
normalized := make([]string, 0, len(cleaned))
|
||||
used := 0
|
||||
for _, tag := range cleaned {
|
||||
tag = truncateRunes(tag, agentTagMaxLen)
|
||||
key := strings.ToLower(tag)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
extra := len([]rune(tag))
|
||||
if len(normalized) > 0 {
|
||||
extra++
|
||||
}
|
||||
if used+extra > agentTagsFieldMax {
|
||||
break
|
||||
}
|
||||
|
||||
seen[key] = struct{}{}
|
||||
normalized = append(normalized, tag)
|
||||
used += extra
|
||||
}
|
||||
return strings.Join(normalized, ","), nil
|
||||
}
|
||||
|
||||
func truncateRunes(value string, maxLen int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxLen {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxLen])
|
||||
}
|
||||
|
||||
// UpdateAgentTags normalises tags and persists them on a single canvas.
|
||||
func (s *AgentService) UpdateAgentTags(userID, canvasID string, tags interface{}) (bool, common.ErrorCode, error) {
|
||||
ok, err := s.CheckCanvasAccess(userID, canvasID)
|
||||
if err != nil {
|
||||
if errors.Is(err, dao.ErrUserCanvasNotFound) {
|
||||
return false, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
return false, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return false, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
|
||||
normalized, nErr := normalizeAgentTags(tags)
|
||||
if nErr != nil {
|
||||
return false, common.CodeBadRequest, nErr
|
||||
}
|
||||
rows, err := s.canvasDAO.UpdateTags(canvasID, normalized)
|
||||
if err != nil {
|
||||
return false, common.CodeServerError, fmt.Errorf("failed to update agent tags: %w", err)
|
||||
}
|
||||
if rows == 0 {
|
||||
if _, getErr := s.canvasDAO.GetByCanvasID(canvasID); getErr != nil {
|
||||
return false, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
return true, common.CodeSuccess, nil
|
||||
}
|
||||
return true, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// CreateAgentSessionRequest is the wire shape for POST
|
||||
// /api/v1/agents/:agent_id/sessions.
|
||||
type CreateAgentSessionRequest struct {
|
||||
UserID string
|
||||
AgentID string
|
||||
Name string
|
||||
Source string
|
||||
DSL json.RawMessage
|
||||
Messages json.RawMessage
|
||||
}
|
||||
|
||||
// CreateAgentSession inserts a fresh conversation row tied to the
|
||||
// given agent canvas. The Phase 5 stub intentionally does NOT run
|
||||
// Canvas(dsl).reset() (eino runtime is still unimplemented in the Go
|
||||
// port); instead it stores a minimal but well-shaped row so that
|
||||
// subsequent ListAgentSessions / GetAgentSession / chat-completion
|
||||
// stubs can return a stable id and the integration suite can verify
|
||||
// the create + read + delete cycle without depending on a real LLM
|
||||
// run. When eino lands, the function will gain a pre-run prologue
|
||||
// pass that calls Canvas.Reset() and stores the assistant message.
|
||||
//
|
||||
// Required columns (per the API4Conversation entity, see
|
||||
// internal/entity/api_token.go:37-52):
|
||||
// - id : 32-hex uuid, matches Python uuid.uuid4().hex
|
||||
// - dialog_id : agent canvas id
|
||||
// - user_id : caller's id
|
||||
// - message : JSON array (default []); GET path normalises it
|
||||
// - reference : JSON object (default {}) so GET-side parsing
|
||||
// does not crash on .chunks
|
||||
// - dsl : JSON map; copied from user_canvas.dsl if the
|
||||
// caller did not pass one
|
||||
// - create_time : unix-millis
|
||||
// - update_time : unix-millis
|
||||
// - create_date : local-time.Truncate(time.Second)
|
||||
// - update_date : local-time.Truncate(time.Second)
|
||||
func (s *AgentService) CreateAgentSession(req *CreateAgentSessionRequest) (*entity.API4Conversation, common.ErrorCode, error) {
|
||||
if req == nil {
|
||||
return nil, common.CodeArgumentError, errors.New("create agent session: nil request")
|
||||
}
|
||||
if req.AgentID == "" {
|
||||
return nil, common.CodeArgumentError, errors.New("create agent session: agent_id is required")
|
||||
}
|
||||
if req.UserID == "" {
|
||||
return nil, common.CodeArgumentError, errors.New("create agent session: user_id is required")
|
||||
}
|
||||
|
||||
ok, err := s.CheckCanvasAccess(req.UserID, req.AgentID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("check canvas access: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
|
||||
messages := req.Messages
|
||||
if len(messages) == 0 {
|
||||
messages = json.RawMessage(`[]`)
|
||||
}
|
||||
reference := json.RawMessage(`{}`)
|
||||
|
||||
var dsl entity.JSONMap
|
||||
if len(req.DSL) > 0 {
|
||||
_ = json.Unmarshal(req.DSL, &dsl)
|
||||
}
|
||||
if len(dsl) == 0 {
|
||||
canvas, gErr := s.canvasDAO.GetByID(req.AgentID)
|
||||
if gErr != nil {
|
||||
if errors.Is(gErr, gorm.ErrRecordNotFound) {
|
||||
return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.")
|
||||
}
|
||||
return nil, common.CodeServerError, fmt.Errorf("load canvas dsl: %w", gErr)
|
||||
}
|
||||
dsl = canvas.DSL
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = "session"
|
||||
}
|
||||
var namePtr = &name
|
||||
var sourcePtr *string
|
||||
if req.Source != "" {
|
||||
sourcePtr = &req.Source
|
||||
}
|
||||
|
||||
id := strings.ReplaceAll(uuid.New().String(), "-", "")[:32]
|
||||
|
||||
// CreateTime / UpdateTime / CreateDate / UpdateDate are filled in
|
||||
// by entity.BaseModel.BeforeCreate when the DAO Create() call runs,
|
||||
// so we do not set them explicitly here.
|
||||
row := &entity.API4Conversation{
|
||||
ID: id,
|
||||
Name: namePtr,
|
||||
DialogID: req.AgentID,
|
||||
UserID: req.UserID,
|
||||
Message: messages,
|
||||
Reference: reference,
|
||||
Source: sourcePtr,
|
||||
DSL: dsl,
|
||||
}
|
||||
if err := s.api4ConversationDAO.Create(row); err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("create agent session: %w", err)
|
||||
}
|
||||
return row, common.CodeSuccess, nil
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
@@ -90,7 +91,7 @@ func TestListVersions_Success(t *testing.T) {
|
||||
})
|
||||
|
||||
svc := NewAgentService()
|
||||
versions, err := svc.ListVersions("canvas-1")
|
||||
versions, err := svc.ListVersions(context.Background(), "user-1", "canvas-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVersions failed: %v", err)
|
||||
}
|
||||
@@ -134,7 +135,7 @@ func TestListVersions_Empty(t *testing.T) {
|
||||
})
|
||||
|
||||
svc := NewAgentService()
|
||||
versions, err := svc.ListVersions("canvas-empty")
|
||||
versions, err := svc.ListVersions(context.Background(), "user-1", "canvas-empty")
|
||||
if err != nil {
|
||||
t.Fatalf("ListVersions failed: %v", err)
|
||||
}
|
||||
@@ -143,136 +144,15 @@ func TestListVersions_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCanvasAccess_Owner verifies that the canvas owner gets access.
|
||||
func TestCheckCanvasAccess_Owner(t *testing.T) {
|
||||
testDB := setupServiceTestDB(t)
|
||||
t.Helper()
|
||||
|
||||
if err := testDB.AutoMigrate(
|
||||
&entity.User{},
|
||||
&entity.UserCanvas{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
orig := dao.DB
|
||||
dao.DB = testDB
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
testDB.Create(&entity.User{ID: "user-1", Nickname: "owner", Email: "a@b.com"})
|
||||
testDB.Create(&entity.UserCanvas{ID: "c-1", UserID: "user-1", Title: sptr("My Agent")})
|
||||
|
||||
svc := NewAgentService()
|
||||
ok, err := svc.CheckCanvasAccess("user-1", "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckCanvasAccess failed: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected owner to have access")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCanvasAccess_NotOwner verifies that a tenant member can access
|
||||
// a team-level canvas.
|
||||
func TestCheckCanvasAccess_NotOwner(t *testing.T) {
|
||||
testDB := setupServiceTestDB(t)
|
||||
t.Helper()
|
||||
|
||||
if err := testDB.AutoMigrate(
|
||||
&entity.User{},
|
||||
&entity.UserCanvas{},
|
||||
&entity.UserTenant{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
orig := dao.DB
|
||||
dao.DB = testDB
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
testDB.Create(&entity.User{ID: "user-1", Nickname: "owner", Email: "a@b.com"})
|
||||
testDB.Create(&entity.User{ID: "user-2", Nickname: "member", Email: "c@d.com"})
|
||||
// user-2 is a member of user-1's tenant (status "1" = active)
|
||||
testDB.Create(&entity.UserTenant{ID: "ut-1", UserID: "user-2", TenantID: "user-1", Role: "member", Status: sptr("1")})
|
||||
// Canvas has team-level permission
|
||||
testDB.Create(&entity.UserCanvas{ID: "c-1", UserID: "user-1", Permission: "team", Title: sptr("Team Agent")})
|
||||
|
||||
svc := NewAgentService()
|
||||
ok, err := svc.CheckCanvasAccess("user-2", "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckCanvasAccess failed: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("expected tenant member to have access to team canvas")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCanvasAccess_PrivateCanvas_Denied verifies that a tenant member
|
||||
// cannot access a private (default "me") canvas.
|
||||
func TestCheckCanvasAccess_PrivateCanvas_Denied(t *testing.T) {
|
||||
testDB := setupServiceTestDB(t)
|
||||
t.Helper()
|
||||
|
||||
if err := testDB.AutoMigrate(
|
||||
&entity.User{},
|
||||
&entity.UserCanvas{},
|
||||
&entity.UserTenant{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
orig := dao.DB
|
||||
dao.DB = testDB
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
testDB.Create(&entity.User{ID: "user-1", Nickname: "owner", Email: "a@b.com"})
|
||||
testDB.Create(&entity.User{ID: "user-2", Nickname: "member", Email: "c@d.com"})
|
||||
// user-2 is a tenant member (status "1" = active)
|
||||
testDB.Create(&entity.UserTenant{ID: "ut-1", UserID: "user-2", TenantID: "user-1", Role: "member", Status: sptr("1")})
|
||||
// Canvas has default "me" permission (private)
|
||||
testDB.Create(&entity.UserCanvas{ID: "c-1", UserID: "user-1", Title: sptr("Private Agent")})
|
||||
|
||||
svc := NewAgentService()
|
||||
ok, err := svc.CheckCanvasAccess("user-2", "c-1")
|
||||
if err != nil {
|
||||
t.Fatalf("CheckCanvasAccess failed: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("expected tenant member to be denied access to private canvas")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCanvasAccess_NotFound verifies behavior for non-existent canvas.
|
||||
func TestCheckCanvasAccess_NotFound(t *testing.T) {
|
||||
testDB := setupServiceTestDB(t)
|
||||
t.Helper()
|
||||
|
||||
if err := testDB.AutoMigrate(
|
||||
&entity.User{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
orig := dao.DB
|
||||
dao.DB = testDB
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
testDB.Create(&entity.User{ID: "user-1", Nickname: "tester", Email: "a@b.com"})
|
||||
|
||||
svc := NewAgentService()
|
||||
_, err := svc.CheckCanvasAccess("user-1", "non-existent")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent canvas")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetVersion_Success verifies getting a specific version by ID.
|
||||
func TestGetVersion_Success(t *testing.T) {
|
||||
testDB := setupServiceTestDB(t)
|
||||
t.Helper()
|
||||
|
||||
if err := testDB.AutoMigrate(
|
||||
&entity.UserCanvas{},
|
||||
&entity.UserCanvasVersion{},
|
||||
&entity.UserTenant{},
|
||||
); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
@@ -281,6 +161,12 @@ func TestGetVersion_Success(t *testing.T) {
|
||||
dao.DB = testDB
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
testDB.Create(&entity.UserCanvas{
|
||||
ID: "canvas-1",
|
||||
UserID: "user-1",
|
||||
Title: sptr("Test Agent"),
|
||||
})
|
||||
|
||||
testDB.Create(&entity.UserCanvasVersion{
|
||||
ID: "v1",
|
||||
UserCanvasID: "canvas-1",
|
||||
@@ -289,7 +175,7 @@ func TestGetVersion_Success(t *testing.T) {
|
||||
})
|
||||
|
||||
svc := NewAgentService()
|
||||
v, err := svc.GetVersion("canvas-1", "v1")
|
||||
v, err := svc.GetVersion(context.Background(), "user-1", "canvas-1", "v1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion failed: %v", err)
|
||||
}
|
||||
@@ -320,7 +206,7 @@ func TestGetVersion_WrongCanvas(t *testing.T) {
|
||||
})
|
||||
|
||||
svc := NewAgentService()
|
||||
_, err := svc.GetVersion("canvas-other", "v1")
|
||||
_, err := svc.GetVersion(context.Background(), "user-other", "canvas-other", "v1")
|
||||
if err == nil {
|
||||
t.Error("expected error for version belonging to another canvas")
|
||||
}
|
||||
@@ -342,7 +228,7 @@ func TestGetVersion_NotFound(t *testing.T) {
|
||||
t.Cleanup(func() { dao.DB = orig })
|
||||
|
||||
svc := NewAgentService()
|
||||
_, err := svc.GetVersion("canvas-1", "non-existent")
|
||||
_, err := svc.GetVersion(context.Background(), "user-1", "canvas-1", "non-existent")
|
||||
if err == nil {
|
||||
t.Error("expected error for non-existent version")
|
||||
}
|
||||
|
||||
@@ -156,8 +156,21 @@ func (m *ModelProviderService) ListProvidersOfTenant(userID string) ([]map[strin
|
||||
|
||||
var result []map[string]interface{}
|
||||
for _, providerName := range providerNames {
|
||||
// Mirror Python's list_providers tenant branch: silently skip system-excluded
|
||||
// factory names (e.g. "Builtin", "Youdao", "FastEmbed", "BAAI",
|
||||
// "siliconflow_intl") and any stale entries whose factory is no longer in
|
||||
// the system pool. See api/apps/services/provider_api_service.py:108.
|
||||
if isExcludedTenantProvider(providerName) {
|
||||
continue
|
||||
}
|
||||
provider, err := dao.GetModelProviderManager().GetProviderByName(providerName)
|
||||
if err != nil {
|
||||
// Treat "provider not found in system pool" as a stale tenant entry
|
||||
// rather than a 500. Mirrors Python's factory_info_mapping.get(name)
|
||||
// truthy gate in api/apps/services/provider_api_service.py:108.
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
continue
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
result = append(result, provider)
|
||||
@@ -166,6 +179,17 @@ func (m *ModelProviderService) ListProvidersOfTenant(userID string) ([]map[strin
|
||||
return result, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// isExcludedTenantProvider returns true for system-pool names that the Python
|
||||
// implementation (api/apps/services/provider_api_service.py:108) intentionally
|
||||
// filters out when listing a tenant's providers.
|
||||
func isExcludedTenantProvider(name string) bool {
|
||||
switch name {
|
||||
case "Youdao", "FastEmbed", "BAAI", "Builtin", "siliconflow_intl":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *ModelProviderService) DeleteModelProvider(providerName, userID string) (common.ErrorCode, error) {
|
||||
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
||||
if err != nil {
|
||||
@@ -336,6 +360,16 @@ func (m *ModelProviderService) ListProviderInstances(providerName, userID string
|
||||
// Check if provider exists
|
||||
provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(tenantID, providerName)
|
||||
if err != nil {
|
||||
// "provider not connected to this tenant" is a normal, expected state
|
||||
// (e.g. demo tenant has never added SiliconFlow). Mirrors the Python
|
||||
// contract in api/apps/services/provider_api_service.py:349-355 which
|
||||
// returns (False, "No provider found for provider '<name>'") on this
|
||||
// path. The REST layer maps that to get_error_data_result with
|
||||
// code=RetCode.DATA_ERROR (=102), so the Go port must do the same —
|
||||
// NOT a 500 server error.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, common.CodeDataError, fmt.Errorf("No provider found for provider '%s'", providerName)
|
||||
}
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
@@ -345,7 +379,12 @@ func (m *ModelProviderService) ListProviderInstances(providerName, userID string
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
// Always emit a non-nil slice so the JSON encoder serializes [] rather
|
||||
// than null when the tenant has no instances on this provider. The
|
||||
// front-end calls .map() / .forEach() on the response array (see
|
||||
// web/src/pages/user-setting/setting-model/...) and would otherwise
|
||||
// crash on a freshly created tenant.
|
||||
result := make([]map[string]interface{}, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
// convert instance.Extra (json string) to map
|
||||
var extra map[string]string
|
||||
@@ -354,13 +393,22 @@ func (m *ModelProviderService) ListProviderInstances(providerName, userID string
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
// Emit snake_case keys to match the IProviderInstance TypeScript
|
||||
// contract (web/src/interfaces/database/llm.ts) and the Python
|
||||
// `TenantModelInstanceService.query(joinedload(...))` response
|
||||
// shape. The Go port previously emitted camelCase
|
||||
// (`instanceName`/`providerID`/`apiKey`), which broke the
|
||||
// `instance.instance_name` reads in
|
||||
// web/src/pages/user-setting/setting-model/components/used-model.tsx
|
||||
// and made `useFetchInstanceModels(providerName,
|
||||
// instance.instance_name)` hit `/api/v1/providers/<p>/instances/undefined/models`.
|
||||
result = append(result, map[string]interface{}{
|
||||
"id": instance.ID,
|
||||
"instanceName": instance.InstanceName,
|
||||
"providerID": instance.ProviderID,
|
||||
"apiKey": instance.APIKey,
|
||||
"status": instance.Status,
|
||||
"extra": instance.Extra,
|
||||
"id": instance.ID,
|
||||
"instance_name": instance.InstanceName,
|
||||
"provider_id": instance.ProviderID,
|
||||
"api_key": instance.APIKey,
|
||||
"status": instance.Status,
|
||||
"extra": instance.Extra,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -399,14 +447,19 @@ func (m *ModelProviderService) ShowProviderInstance(providerName, instanceName,
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
// Emit snake_case keys to match the IProviderInstance TypeScript
|
||||
// contract — see ListProviderInstances above. The previous shape
|
||||
// mixed conventions (`apikey` lowercase, `instanceName`/`providerID`
|
||||
// camelCase) and broke the front-end's `instance.api_key` /
|
||||
// `instance.instance_name` reads.
|
||||
result := map[string]interface{}{
|
||||
"id": instance.ID,
|
||||
"instanceName": instance.InstanceName,
|
||||
"providerID": instance.ProviderID,
|
||||
"status": instance.Status,
|
||||
"apikey": instance.APIKey,
|
||||
"region": extra["region"],
|
||||
"base_url": extra["base_url"],
|
||||
"id": instance.ID,
|
||||
"instance_name": instance.InstanceName,
|
||||
"provider_id": instance.ProviderID,
|
||||
"status": instance.Status,
|
||||
"api_key": instance.APIKey,
|
||||
"region": extra["region"],
|
||||
"base_url": extra["base_url"],
|
||||
}
|
||||
|
||||
return result, common.CodeSuccess, nil
|
||||
@@ -688,6 +741,174 @@ func (m *ModelProviderService) ShowTask(providerName, instanceName, taskID, user
|
||||
return taskResponse, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// ListTenantAddedModels returns the list of models the tenant has "added"
|
||||
// across all of their provider instances. It is the Go port of Python's
|
||||
// models_api_service.list_tenant_added_models
|
||||
// (api/apps/services/models_api_service.py:300) and is the response
|
||||
// contract for GET /api/v1/models — the endpoint that
|
||||
// web/src/hooks/use-llm-request.tsx → useFetchAllAddedModels consumes.
|
||||
//
|
||||
// Per the Python algorithm, for each (provider × instance) we cross-
|
||||
// reference the factory catalog (internal/entity/models/model.go
|
||||
// ProviderManager.Providers) with the per-tenant overrides in
|
||||
// tenant_model:
|
||||
// active_model_types = tenant_model rows with status='active'
|
||||
// inactive_model_types = tenant_model rows with status='inactive'
|
||||
// factory_model_types = provider.Models[i].ModelTypes
|
||||
// model_types = (factory ∪ active) \ inactive
|
||||
//
|
||||
// The Go port never WRITES to tenant_model, so in practice every model
|
||||
// from the factory catalog is treated as added unless explicitly
|
||||
// disabled (which today can only happen via SQL — the Go port has no
|
||||
// enable/disable endpoint path that mutates tenant_model). This is
|
||||
// intentional: the previous Go contract mistakenly routed /api/v1/models
|
||||
// to ListTenantDefaultModels (which only enumerates the 6-7 default
|
||||
// tenant fields and returned `[]` for any tenant without defaults),
|
||||
// breaking the front-end's "View Models" list entirely.
|
||||
func (m *ModelProviderService) ListTenantAddedModels(userID, modelTypeFilter string) ([]map[string]interface{}, common.ErrorCode, error) {
|
||||
// Resolve tenant. Match the convention used elsewhere in this file
|
||||
// (see ListProviderInstances, DropProviderInstances): take the first
|
||||
// tenant where the user has role=owner.
|
||||
tenants, err := m.userTenantDAO.GetByUserIDAndRole(userID, "owner")
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if len(tenants) == 0 {
|
||||
// No tenant for the user → empty list, code=0. Python returns
|
||||
// get_result(data=[]) for the same path.
|
||||
return []map[string]interface{}{}, common.CodeSuccess, nil
|
||||
}
|
||||
tenantID := tenants[0].TenantID
|
||||
|
||||
if modelTypeFilter != "" {
|
||||
modelTypeFilter = strings.ToLower(strings.TrimSpace(modelTypeFilter))
|
||||
}
|
||||
|
||||
providers, err := m.modelProviderDAO.GetByTenantID(tenantID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return []map[string]interface{}{}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
providerIDs := make([]string, 0, len(providers))
|
||||
providerInfoByID := make(map[string]*entity.TenantModelProvider, len(providers))
|
||||
for _, p := range providers {
|
||||
providerIDs = append(providerIDs, p.ID)
|
||||
providerInfoByID[p.ID] = p
|
||||
}
|
||||
|
||||
instances, err := m.modelInstanceDAO.GetByProviderIDs(providerIDs)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if len(instances) == 0 {
|
||||
return []map[string]interface{}{}, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
instanceIDs := make([]string, 0, len(instances))
|
||||
instanceInfoByID := make(map[string]*entity.TenantModelInstance, len(instances))
|
||||
for _, inst := range instances {
|
||||
instanceIDs = append(instanceIDs, inst.ID)
|
||||
instanceInfoByID[inst.ID] = inst
|
||||
}
|
||||
|
||||
// Per-tenant enable/disable overrides. In the Go port this is
|
||||
// typically empty (no writers), but we still honor active/inactive
|
||||
// rows for correctness and parity.
|
||||
modelRecords, err := m.modelDAO.GetModelsByProviderIDsAndInstanceIDs(providerIDs, instanceIDs)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
activeByKey := make(map[string][]string)
|
||||
inactiveByKey := make(map[string][]string)
|
||||
for _, rec := range modelRecords {
|
||||
key := rec.ProviderID + "@" + rec.InstanceID + "@" + rec.ModelName
|
||||
if rec.Status == "inactive" {
|
||||
inactiveByKey[key] = append(inactiveByKey[key], rec.ModelType)
|
||||
} else {
|
||||
activeByKey[key] = append(activeByKey[key], rec.ModelType)
|
||||
}
|
||||
}
|
||||
|
||||
// Group instances by provider_name for the outer loop.
|
||||
instancesByProviderName := make(map[string][]*entity.TenantModelInstance)
|
||||
for _, inst := range instances {
|
||||
p, ok := providerInfoByID[inst.ProviderID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
instancesByProviderName[p.ProviderName] = append(instancesByProviderName[p.ProviderName], inst)
|
||||
}
|
||||
|
||||
providerManager := dao.GetModelProviderManager()
|
||||
added := make([]map[string]interface{}, 0)
|
||||
|
||||
// factory rank is not present in the Go entity.Provider struct, so we
|
||||
// follow Python's stable ordering intent (factory rank desc, then
|
||||
// provider_name, then instance_name) by simply iterating providers in
|
||||
// the tenant's own order. With one provider today this is a no-op.
|
||||
for _, p := range providers {
|
||||
factory := providerManager.FindProvider(p.ProviderName)
|
||||
if factory == nil {
|
||||
// Factory not in the static catalog. The tenant has linked
|
||||
// a provider we have no model list for. Skip — there is
|
||||
// nothing to expose.
|
||||
continue
|
||||
}
|
||||
factoryInstances := instancesByProviderName[p.ProviderName]
|
||||
if len(factoryInstances) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, llm := range factory.Models {
|
||||
if modelTypeFilter != "" {
|
||||
match := false
|
||||
for _, t := range llm.ModelTypes {
|
||||
if strings.EqualFold(t, modelTypeFilter) {
|
||||
match = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
}
|
||||
for _, inst := range factoryInstances {
|
||||
key := p.ID + "@" + inst.ID + "@" + llm.Name
|
||||
// Set-based merge: factory types ∪ active overrides \ inactive overrides.
|
||||
mergedSet := make(map[string]struct{}, len(llm.ModelTypes)+len(activeByKey[key]))
|
||||
for _, t := range llm.ModelTypes {
|
||||
mergedSet[t] = struct{}{}
|
||||
}
|
||||
for _, t := range activeByKey[key] {
|
||||
mergedSet[t] = struct{}{}
|
||||
}
|
||||
for _, t := range inactiveByKey[key] {
|
||||
delete(mergedSet, t)
|
||||
}
|
||||
if len(mergedSet) == 0 {
|
||||
continue
|
||||
}
|
||||
merged := make([]string, 0, len(mergedSet))
|
||||
for t := range mergedSet {
|
||||
merged = append(merged, t)
|
||||
}
|
||||
added = append(added, map[string]interface{}{
|
||||
"model_type": merged,
|
||||
"name": llm.Name,
|
||||
"provider_id": inst.ProviderID,
|
||||
"provider_name": p.ProviderName,
|
||||
"instance_id": inst.ID,
|
||||
"instance_name": inst.InstanceName,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return added, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
func (m *ModelProviderService) AlterProviderInstance(providerName, instanceName, newInstanceName, apiKey, userID string) (common.ErrorCode, error) {
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
@@ -708,6 +929,15 @@ func (m *ModelProviderService) DropProviderInstances(providerName, userID string
|
||||
// Check if provider exists
|
||||
provider, err := m.modelProviderDAO.GetByTenantIDAndProviderName(tenantID, providerName)
|
||||
if err != nil {
|
||||
// Tenant hasn't connected this provider. The DELETE request is a
|
||||
// no-op in that case — mirrors Python's drop_provider_instances in
|
||||
// api/apps/services/provider_api_service.py, which simply iterates
|
||||
// the (empty) instance list. The previous contract bubbled
|
||||
// "record not found" as a 500, which broke the front-end "remove
|
||||
// instance" flow when the UI's snapshot was slightly stale.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return common.CodeSuccess, nil
|
||||
}
|
||||
return common.CodeServerError, err
|
||||
}
|
||||
|
||||
@@ -716,6 +946,12 @@ func (m *ModelProviderService) DropProviderInstances(providerName, userID string
|
||||
var tenantModelInstance *entity.TenantModelInstance
|
||||
tenantModelInstance, err = m.modelInstanceDAO.GetByProviderIDAndInstanceName(provider.ID, instanceName)
|
||||
if err != nil {
|
||||
// The instance name isn't in the DB (e.g. UI holds a stale id
|
||||
// after the user already removed it on another tab). Match
|
||||
// Python and skip silently instead of returning 500.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
continue
|
||||
}
|
||||
return common.CodeServerError, err
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ func (m *MockRedisClient) Set(key, value string) {
|
||||
|
||||
// TestNewSynonym tests the constructor
|
||||
func TestNewSynonym(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
t.Run("without redis", func(t *testing.T) {
|
||||
s := NewSynonym(nil, "", testSynonymWordNetDir)
|
||||
if s == nil {
|
||||
|
||||
34
internal/service/nlp/wordnet_helpers_test.go
Normal file
34
internal/service/nlp/wordnet_helpers_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package nlp
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// requireWordNetData skips the test when the WordNet dictionary files
|
||||
// are not present at testWordNetDir. The data ships outside the repo
|
||||
// (downloaded by Python `download_deps.py`), so CI hosts without it
|
||||
// must skip rather than fail. The check looks for the lemma-pos offset
|
||||
// file the loader opens first.
|
||||
func requireWordNetData(t testing.TB) {
|
||||
t.Helper()
|
||||
probe := filepath.Join(testWordNetDir, "index.noun")
|
||||
if _, err := os.Stat(probe); err != nil {
|
||||
t.Skipf("WordNet data not present at %s; skipping (run download_deps.py to populate)", testWordNetDir)
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
var testWordNetDir string
|
||||
|
||||
func TestNewWordNet(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
wn, err := NewWordNet(testWordNetDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WordNet: %v", err)
|
||||
@@ -45,6 +46,7 @@ func TestNewWordNet(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMorphy(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
wn, err := NewWordNet(testWordNetDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WordNet: %v", err)
|
||||
@@ -71,6 +73,7 @@ func TestMorphy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSynsets(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
wn, err := NewWordNet(testWordNetDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WordNet: %v", err)
|
||||
@@ -141,6 +144,7 @@ func TestSynsets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSynsetsDetailed(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
wn, err := NewWordNet(testWordNetDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WordNet: %v", err)
|
||||
@@ -171,6 +175,7 @@ func TestSynsetsDetailed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSynsetsConsistencyWithPython(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
wn, err := NewWordNet(testWordNetDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WordNet: %v", err)
|
||||
@@ -209,6 +214,7 @@ func TestSynsetsConsistencyWithPython(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSynsetContent(t *testing.T) {
|
||||
requireWordNetData(t)
|
||||
wn, err := NewWordNet(testWordNetDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create WordNet: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user