190 lines
6.4 KiB
Go
190 lines
6.4 KiB
Go
// 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 {
|
||
BaseURL, ResourceID, APIKey, EventType, ProfileType string
|
||
Timeout time.Duration
|
||
}
|
||
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
|
||
}
|