2026-09-06 17:04:11 +08:00
|
|
|
|
// Package vikingmemory integrates Volcano's managed memory collection, not OpenViking.
|
|
|
|
|
|
package vikingmemory
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/url"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
|
|
"data-terminal-cloud/internal/outbox"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const AssistantID = "platform_companion_v1"
|
|
|
|
|
|
|
|
|
|
|
|
type Options struct {
|
2026-09-06 17:27:11 +08:00
|
|
|
|
BaseURL, ResourceID, CollectionName, APIKey, EventType, ProfileType string
|
|
|
|
|
|
Timeout time.Duration
|
2026-09-06 17:04:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
type Client struct {
|
|
|
|
|
|
options Options
|
|
|
|
|
|
http *http.Client
|
|
|
|
|
|
}
|
|
|
|
|
|
type Message struct {
|
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
|
RoleID string `json:"role_id"`
|
|
|
|
|
|
Time int64 `json:"time"`
|
|
|
|
|
|
}
|
|
|
|
|
|
type Item struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
UserID []string `json:"user_id"`
|
|
|
|
|
|
AssistantID []string `json:"assistant_id"`
|
|
|
|
|
|
SessionID string `json:"session_id"`
|
|
|
|
|
|
Score float64 `json:"score"`
|
|
|
|
|
|
MemoryInfo map[string]json.RawMessage `json:"memory_info"`
|
|
|
|
|
|
Content string `json:"-"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func NewClient(options Options) (*Client, error) {
|
|
|
|
|
|
if options.BaseURL == "" {
|
|
|
|
|
|
options.BaseURL = "https://api-knowledgebase.mlp.cn-beijing.volces.com"
|
|
|
|
|
|
}
|
|
|
|
|
|
u, e := url.Parse(options.BaseURL)
|
|
|
|
|
|
if e != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
|
|
|
|
|
return nil, errors.New("Viking requires an HTTPS endpoint")
|
|
|
|
|
|
}
|
|
|
|
|
|
if options.ResourceID == "" || strings.TrimSpace(options.APIKey) == "" || options.EventType == "" || options.ProfileType == "" || options.Timeout <= 0 {
|
|
|
|
|
|
return nil, errors.New("Viking resource, key, event/profile types and timeout are required")
|
|
|
|
|
|
}
|
|
|
|
|
|
options.BaseURL = strings.TrimRight(options.BaseURL, "/")
|
|
|
|
|
|
options.APIKey = strings.TrimSpace(options.APIKey)
|
|
|
|
|
|
return &Client{options: options, http: &http.Client{Timeout: options.Timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func UserID(tenantID, subjectID string) string {
|
|
|
|
|
|
digest := sha256.Sum256([]byte(tenantID + ":" + subjectID))
|
|
|
|
|
|
return "u_" + hex.EncodeToString(digest[:])
|
|
|
|
|
|
}
|
|
|
|
|
|
func SessionID(conversationID string, batch int) string {
|
|
|
|
|
|
return "c_" + strings.ReplaceAll(conversationID, "-", "") + fmt.Sprintf("_%d", batch)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (c *Client) call(ctx context.Context, path string, payload map[string]any, target any) error {
|
|
|
|
|
|
payload["resource_id"] = c.options.ResourceID
|
|
|
|
|
|
body, err := json.Marshal(payload)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.options.BaseURL+path, bytes.NewReader(body))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+c.options.APIKey)
|
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
|
resp, err := c.http.Do(req)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return outbox.Retryable(errors.New("Viking request unavailable"))
|
|
|
|
|
|
}
|
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
var result struct {
|
|
|
|
|
|
Code *int `json:"code"`
|
|
|
|
|
|
Data json.RawMessage `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
err = json.NewDecoder(io.LimitReader(resp.Body, 2*1024*1024)).Decode(&result)
|
|
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 || err != nil || result.Code == nil || *result.Code != 0 {
|
|
|
|
|
|
code := -1
|
|
|
|
|
|
if result.Code != nil {
|
|
|
|
|
|
code = *result.Code
|
|
|
|
|
|
}
|
|
|
|
|
|
failure := fmt.Errorf("Viking %s HTTP %d code %d", path, resp.StatusCode, code)
|
|
|
|
|
|
if resp.StatusCode == 429 || resp.StatusCode >= 500 || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
|
|
|
|
|
return outbox.Retryable(failure)
|
|
|
|
|
|
}
|
|
|
|
|
|
return failure
|
|
|
|
|
|
}
|
|
|
|
|
|
if target != nil {
|
|
|
|
|
|
if err := json.Unmarshal(result.Data, target); err != nil {
|
|
|
|
|
|
return errors.New("Viking returned invalid data")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func (c *Client) AddSession(ctx context.Context, sessionID, userID string, messages []Message, when int64) error {
|
|
|
|
|
|
if sessionID == "" || userID == "" || len(messages) == 0 || len(messages) > 100 {
|
|
|
|
|
|
return errors.New("invalid Viking session")
|
|
|
|
|
|
}
|
|
|
|
|
|
return c.call(ctx, "/api/memory/session/add", map[string]any{"session_id": sessionID, "messages": messages, "metadata": map[string]any{"default_user_id": userID, "default_assistant_id": AssistantID, "time": when}, "extract_memory_type": []string{c.options.EventType, c.options.ProfileType}}, nil)
|
|
|
|
|
|
}
|
|
|
|
|
|
func (c *Client) Search(ctx context.Context, userID, query string, limit int) ([]Item, error) {
|
|
|
|
|
|
if userID == "" || limit < 1 || limit > 5 {
|
|
|
|
|
|
return nil, errors.New("Viking user and bounded limit are required")
|
|
|
|
|
|
}
|
|
|
|
|
|
items := []Item{}
|
|
|
|
|
|
remaining := 800
|
|
|
|
|
|
for _, kind := range []struct{ path, rule string }{{"profile", c.options.ProfileType}, {"event", c.options.EventType}} {
|
|
|
|
|
|
payload := map[string]any{"filter": map[string]any{"user_id": []string{userID}, "assistant_id": []string{AssistantID}, "memory_type": []string{kind.rule}}, "limit": limit}
|
|
|
|
|
|
if strings.TrimSpace(query) != "" {
|
|
|
|
|
|
payload["query"] = query
|
|
|
|
|
|
}
|
|
|
|
|
|
var data struct {
|
|
|
|
|
|
Items []Item `json:"result_list"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := c.call(ctx, "/api/memory/"+kind.path+"/search", payload, &data); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, item := range data.Items {
|
|
|
|
|
|
if !contains(item.UserID, userID) || !contains(item.AssistantID, AssistantID) {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
parts := []string{}
|
|
|
|
|
|
for _, key := range []string{"summary", "user_profile"} {
|
|
|
|
|
|
raw := item.MemoryInfo[key]
|
|
|
|
|
|
if len(raw) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
var text string
|
|
|
|
|
|
if json.Unmarshal(raw, &text) != nil {
|
|
|
|
|
|
text = string(raw)
|
|
|
|
|
|
}
|
|
|
|
|
|
if text != "" && text != "{}" {
|
|
|
|
|
|
parts = append(parts, text)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
content := []rune(strings.Join(parts, ";"))
|
|
|
|
|
|
if len(content) > 400 {
|
|
|
|
|
|
content = content[:400]
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(content) > remaining {
|
|
|
|
|
|
content = content[:remaining]
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(content) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
remaining -= len(content)
|
|
|
|
|
|
item.Content = string(content)
|
|
|
|
|
|
items = append(items, item)
|
|
|
|
|
|
if len(items) >= limit || remaining <= 0 {
|
|
|
|
|
|
return items, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return items, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
func contains(values []string, expected string) bool {
|
|
|
|
|
|
for _, v := range values {
|
|
|
|
|
|
if v == expected {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (c *Client) DeleteUser(ctx context.Context, userID string) error {
|
|
|
|
|
|
if userID == "" {
|
|
|
|
|
|
return errors.New("Viking deletion requires an exact user")
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, kind := range []string{"event", "profile"} {
|
|
|
|
|
|
if err := c.call(ctx, "/api/memory/"+kind+"/batch_delete", map[string]any{"delete_type": "full", "filter": map[string]any{"user_id": []string{userID}, "assistant_id": []string{AssistantID}}}, nil); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|