This commit is contained in:
+22
-8
@@ -14,6 +14,7 @@ import (
|
||||
"data-terminal-cloud/internal/billing"
|
||||
"data-terminal-cloud/internal/cache"
|
||||
"data-terminal-cloud/internal/config"
|
||||
"data-terminal-cloud/internal/contentmodel"
|
||||
"data-terminal-cloud/internal/contentpolicy"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/dataexport"
|
||||
@@ -137,6 +138,21 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
var opsDeviceService *opsdevice.Service
|
||||
var memoryToolService *memorytool.Service
|
||||
var memoryToolSigner *memorytool.TokenSigner
|
||||
var contentModelService *contentmodel.Service
|
||||
var contentModelRegistry *contentmodel.Registry
|
||||
if serviceName == "api" || serviceName == "worker" {
|
||||
var fallback extraction.Generator = extraction.DeterministicGenerator{}
|
||||
if cfg.ContentGenerator == "ark" {
|
||||
fallback, err = extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create fallback Ark content generator: %w", err)
|
||||
}
|
||||
}
|
||||
contentModelRegistry, err = contentmodel.NewRegistry(databasePool, cfg.ContentModelMasterKey, fallback)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create content model registry: %w", err)
|
||||
}
|
||||
}
|
||||
if serviceName == "api" || serviceName == "callbacks" {
|
||||
diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
|
||||
if err != nil {
|
||||
@@ -144,6 +160,10 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
}
|
||||
}
|
||||
if serviceName == "api" {
|
||||
contentModelService, err = contentmodel.NewService(contentModelRegistry, audit.NewRepository())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create content model service: %w", err)
|
||||
}
|
||||
memoryToolSigner, err = memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create memory tool signer: %w", err)
|
||||
@@ -354,14 +374,7 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
}
|
||||
outboxWorker.Register("conversation.agent_stop_requested", stopProcessor.Handle)
|
||||
outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle)
|
||||
var contentGenerator extraction.Generator = extraction.DeterministicGenerator{}
|
||||
if cfg.ContentGenerator == "ark" {
|
||||
arkGenerator, generatorErr := extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout)
|
||||
if generatorErr != nil {
|
||||
return fmt.Errorf("create Ark content generator: %w", generatorErr)
|
||||
}
|
||||
contentGenerator = arkGenerator
|
||||
}
|
||||
var contentGenerator extraction.Generator = contentModelRegistry
|
||||
var contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{}
|
||||
if cfg.ContentSafetyProvider == "managed_http" {
|
||||
managedSafety, safetyErr := contentpolicy.NewManagedClassifier(cfg.ContentSafetyEndpoint, cfg.ContentSafetyAPIKey, cfg.ContentSafetyPolicyRef, cfg.ContentSafetyTimeout)
|
||||
@@ -476,6 +489,7 @@ func Run(ctx context.Context, serviceName string) error {
|
||||
DiagnosticService: diagnosticService,
|
||||
OpsAccessService: opsAccessService,
|
||||
OpsDeviceService: opsDeviceService,
|
||||
ContentModelService: contentModelService,
|
||||
ConversationCreator: conversationCreator,
|
||||
ConversationActivator: conversationCreator,
|
||||
DeviceConversationStateReader: conversationCreator,
|
||||
|
||||
@@ -48,6 +48,7 @@ type Config struct {
|
||||
ProviderMaxActiveTasks int
|
||||
DeviceLocalSecret string
|
||||
DeviceCredentialMasterKey string
|
||||
ContentModelMasterKey string
|
||||
DeviceClockSkew time.Duration
|
||||
RTCAppID string
|
||||
RTCAppKey string
|
||||
@@ -226,7 +227,7 @@ func Load(serviceName string) (Config, error) {
|
||||
}
|
||||
environment := stringEnv("APP_ENV", EnvironmentLocal)
|
||||
secretValues, err := loadSecretValues(
|
||||
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
||||
"DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "MODEL_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET",
|
||||
"CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET",
|
||||
"MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN",
|
||||
"OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY", "MEMORY_TOOL_SIGNING_SECRET", "PROVIDER_MEMORY_API_KEY",
|
||||
@@ -236,6 +237,7 @@ func Load(serviceName string) (Config, error) {
|
||||
}
|
||||
deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
|
||||
deviceCredentialMasterKey := secretValues["DEVICE_CREDENTIAL_MASTER_KEY"]
|
||||
contentModelMasterKey := secretValues["MODEL_CREDENTIAL_MASTER_KEY"]
|
||||
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
|
||||
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
|
||||
shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"]
|
||||
@@ -252,6 +254,9 @@ func Load(serviceName string) (Config, error) {
|
||||
if deviceCredentialMasterKey == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||
deviceCredentialMasterKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
||||
}
|
||||
if contentModelMasterKey == "" && (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||
contentModelMasterKey = "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100"
|
||||
}
|
||||
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
|
||||
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
|
||||
}
|
||||
@@ -311,6 +316,7 @@ func Load(serviceName string) (Config, error) {
|
||||
RecoveryStoppingAfter: recoveryStoppingAfter,
|
||||
DeviceLocalSecret: deviceLocalSecret,
|
||||
DeviceCredentialMasterKey: deviceCredentialMasterKey,
|
||||
ContentModelMasterKey: contentModelMasterKey,
|
||||
DeviceClockSkew: deviceClockSkew,
|
||||
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
|
||||
RTCAppKey: secretValues["RTC_APP_KEY"],
|
||||
@@ -478,6 +484,12 @@ func (c Config) Validate() error {
|
||||
return errors.New("DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
}
|
||||
if c.ServiceName == "api" || c.ServiceName == "worker" {
|
||||
decodedModelKey, err := hex.DecodeString(c.ContentModelMasterKey)
|
||||
if err != nil || len(decodedModelKey) != 32 {
|
||||
return errors.New("MODEL_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
}
|
||||
if c.DeviceClockSkew <= 0 {
|
||||
return errors.New("DEVICE_CLOCK_SKEW must be positive")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package contentmodel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"data-terminal-cloud/internal/audit"
|
||||
"data-terminal-cloud/internal/database"
|
||||
"data-terminal-cloud/internal/extraction"
|
||||
"data-terminal-cloud/internal/identity"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAccessDenied = errors.New("content model access denied")
|
||||
ErrNotFound = errors.New("content model not found")
|
||||
ErrInvalid = errors.New("invalid content model configuration")
|
||||
ErrVersion = errors.New("content model version conflict")
|
||||
ErrTestRequired = errors.New("content model must pass a connection test before activation")
|
||||
ErrActiveRevoke = errors.New("active content model must be replaced before revocation")
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ModelID string `json:"model_id"`
|
||||
CredentialHint string `json:"credential_hint"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Status string `json:"status"`
|
||||
TestStatus string `json:"test_status"`
|
||||
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||
LastErrorCode string `json:"last_error_code,omitempty"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Provider string `json:"provider"`
|
||||
BaseURL string `json:"base_url"`
|
||||
ModelID string `json:"model_id"`
|
||||
APIKey string `json:"api_key"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
pool *pgxpool.Pool
|
||||
aead cipher.AEAD
|
||||
fallback extraction.Generator
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
registry *Registry
|
||||
audit *audit.Repository
|
||||
}
|
||||
|
||||
type storedModel struct {
|
||||
Model
|
||||
tenantInternalID string
|
||||
encrypted []byte
|
||||
nonce []byte
|
||||
keyVersion int64
|
||||
}
|
||||
|
||||
type actor struct {
|
||||
tenantID, tenantPublicID, userID, userPublicID string
|
||||
canManage bool
|
||||
}
|
||||
|
||||
func NewRegistry(pool *pgxpool.Pool, masterKeyHex string, fallback extraction.Generator) (*Registry, error) {
|
||||
if pool == nil || fallback == nil {
|
||||
return nil, errors.New("content model database and fallback generator are required")
|
||||
}
|
||||
key, err := hex.DecodeString(strings.TrimSpace(masterKeyHex))
|
||||
if err != nil || len(key) != 32 {
|
||||
return nil, errors.New("content model master key must be 32 bytes encoded as 64 hexadecimal characters")
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create content model cipher: %w", err)
|
||||
}
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create content model AEAD: %w", err)
|
||||
}
|
||||
return &Registry{pool: pool, aead: aead, fallback: fallback}, nil
|
||||
}
|
||||
|
||||
func NewService(registry *Registry, auditRepository *audit.Repository) (*Service, error) {
|
||||
if registry == nil || auditRepository == nil {
|
||||
return nil, errors.New("content model registry and audit repository are required")
|
||||
}
|
||||
return &Service{registry: registry, audit: auditRepository}, nil
|
||||
}
|
||||
|
||||
// Generate resolves the active tenant model at execution time. This makes an
|
||||
// activation change effective for the next summary job without restarting the worker.
|
||||
func (r *Registry) Generate(ctx context.Context, input extraction.GenerationInput) (extraction.GenerationResult, error) {
|
||||
if strings.TrimSpace(input.TenantID) == "" {
|
||||
return r.fallback.Generate(ctx, input)
|
||||
}
|
||||
item, err := r.active(ctx, input.TenantID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return r.fallback.Generate(ctx, input)
|
||||
}
|
||||
if err != nil {
|
||||
return extraction.GenerationResult{}, err
|
||||
}
|
||||
generator, err := r.generator(item)
|
||||
if err != nil {
|
||||
return extraction.GenerationResult{}, err
|
||||
}
|
||||
return generator.Generate(ctx, input)
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Model, error) {
|
||||
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.registry.pool.Query(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status<>'revoked' ORDER BY (cm.status='active') DESC,cm.updated_at DESC`, current.tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Model{}
|
||||
for rows.Next() {
|
||||
item, scanErr := scanModel(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, item.Model)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, principal identity.Principal, input CreateInput, requestID string) (Model, error) {
|
||||
normalizeInput(&input)
|
||||
if err := validateInput(input); err != nil {
|
||||
return Model{}, err
|
||||
}
|
||||
current, err := s.resolveActor(ctx, principal, input.TenantID)
|
||||
if err != nil {
|
||||
return Model{}, err
|
||||
}
|
||||
if !current.canManage {
|
||||
return Model{}, ErrAccessDenied
|
||||
}
|
||||
var result Model
|
||||
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||
publicID := uuid.NewString()
|
||||
nonce := make([]byte, s.registry.aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
const keyVersion int64 = 1
|
||||
encrypted := s.registry.aead.Seal(nil, nonce, []byte(input.APIKey), modelAAD(current.tenantID, publicID, keyVersion))
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO content_model_config(public_id,tenant_id,display_name,provider,base_url,model_id,encrypted_api_key,nonce,key_version,credential_hint,temperature,max_tokens,created_by,updated_by)
|
||||
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$13::uuid)
|
||||
RETURNING public_id::text,$14::text,display_name,provider,base_url,model_id,credential_hint,temperature::float8,max_tokens,status,test_status,last_tested_at,COALESCE(last_error_code,''),activated_at,version,created_at,updated_at,tenant_id::text,encrypted_api_key,nonce,key_version`,
|
||||
publicID, current.tenantID, input.DisplayName, input.Provider, input.BaseURL, input.ModelID, encrypted, nonce, keyVersion, credentialHint(input.APIKey), input.Temperature, input.MaxTokens, current.userID, current.tenantPublicID)
|
||||
item, scanErr := scanModel(row)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
result = item.Model
|
||||
resourceID := result.ID
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.create", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "配置聊天内容总结模型", Result: "success", Detail: map[string]any{"provider": result.Provider, "model_id": result.ModelID}})
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Test(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string) (Model, error) {
|
||||
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||
if err != nil || !current.canManage {
|
||||
return Model{}, ErrAccessDenied
|
||||
}
|
||||
item, err := s.get(ctx, current.tenantID, publicID)
|
||||
if err != nil {
|
||||
return Model{}, err
|
||||
}
|
||||
generator, err := s.registry.generator(item)
|
||||
testStatus, errorCode := "passed", ""
|
||||
if err == nil {
|
||||
_, err = generator.Generate(ctx, extraction.GenerationInput{TenantID: current.tenantID, Content: "模型连通性测试,不包含长者或家庭数据。"})
|
||||
}
|
||||
if err != nil {
|
||||
testStatus, errorCode = "failed", normalizedErrorCode(err)
|
||||
}
|
||||
var result Model
|
||||
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||
row := tx.QueryRow(ctx, modelUpdateReturning+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID)
|
||||
updated, scanErr := scanModel(row)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
result = updated.Model
|
||||
resourceID := result.ID
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: testStatus, Detail: map[string]any{"error_code": errorCode}})
|
||||
})
|
||||
if updateErr != nil {
|
||||
return Model{}, updateErr
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) Activate(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) {
|
||||
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||
if err != nil || !current.canManage {
|
||||
return Model{}, ErrAccessDenied
|
||||
}
|
||||
var result Model
|
||||
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||
var internalID string
|
||||
var version int64
|
||||
var testStatus, status string
|
||||
if err := tx.QueryRow(ctx, `SELECT id::text,version,test_status,status FROM content_model_config WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, current.tenantID, publicID).Scan(&internalID, &version, &testStatus, &status); errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if version != expectedVersion {
|
||||
return ErrVersion
|
||||
}
|
||||
if status == "revoked" {
|
||||
return ErrNotFound
|
||||
}
|
||||
if testStatus != "passed" {
|
||||
return ErrTestRequired
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE content_model_config SET status='inactive',activated_at=NULL,updated_at=now(),version=version+1,updated_by=$2::uuid WHERE tenant_id=$1::uuid AND status='active' AND id<>$3::uuid`, current.tenantID, current.userID, internalID); err != nil {
|
||||
return err
|
||||
}
|
||||
row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='active',activated_at=now(),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID)
|
||||
updated, scanErr := scanModel(row)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
result = updated.Model
|
||||
resourceID := result.ID
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.activate", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "激活聊天总结模型", Result: "success", Detail: map[string]any{"model_id": result.ModelID}})
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) Revoke(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) {
|
||||
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||
if err != nil || !current.canManage {
|
||||
return Model{}, ErrAccessDenied
|
||||
}
|
||||
var result Model
|
||||
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||
item, lockErr := s.getTx(ctx, tx, current.tenantID, publicID, true)
|
||||
if lockErr != nil {
|
||||
return lockErr
|
||||
}
|
||||
if item.Version != expectedVersion {
|
||||
return ErrVersion
|
||||
}
|
||||
if item.Status == "active" {
|
||||
return ErrActiveRevoke
|
||||
}
|
||||
row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='revoked',encrypted_api_key=decode('','hex'),nonce=decode('','hex'),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID)
|
||||
updated, scanErr := scanModel(row)
|
||||
if scanErr != nil {
|
||||
return scanErr
|
||||
}
|
||||
result = updated.Model
|
||||
resourceID := result.ID
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.revoke", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "移除未激活的总结模型", Result: "success"})
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *Registry) active(ctx context.Context, tenantID string) (storedModel, error) {
|
||||
row := r.pool.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status='active'`, tenantID)
|
||||
return scanModel(row)
|
||||
}
|
||||
|
||||
func (r *Registry) generator(item storedModel) (extraction.Generator, error) {
|
||||
key, err := r.aead.Open(nil, item.nonce, item.encrypted, modelAAD(item.tenantInternalID, item.ID, item.keyVersion))
|
||||
if err != nil || len(key) < 8 {
|
||||
return nil, errors.New("content model credential cannot be decrypted")
|
||||
}
|
||||
return extraction.NewCompatibleGenerator(item.Provider, item.BaseURL, string(key), item.ModelID, item.Temperature, item.MaxTokens, 30*time.Second)
|
||||
}
|
||||
|
||||
func (s *Service) get(ctx context.Context, tenantID, publicID string) (storedModel, error) {
|
||||
return s.getTx(ctx, s.registry.pool, tenantID, publicID, false)
|
||||
}
|
||||
|
||||
type rowQueryer interface {
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func (s *Service) getTx(ctx context.Context, q rowQueryer, tenantID, publicID string, lock bool) (storedModel, error) {
|
||||
lockClause := ""
|
||||
if lock {
|
||||
lockClause = " FOR UPDATE OF cm"
|
||||
}
|
||||
item, err := scanModel(q.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`+lockClause, tenantID, publicID))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return storedModel{}, ErrNotFound
|
||||
}
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string) (actor, error) {
|
||||
var result actor
|
||||
err := s.registry.pool.QueryRow(ctx, `
|
||||
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
|
||||
COALESCE(bool_or(hm.role IN ('owner','admin') AND hm.status='active'),false)
|
||||
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
|
||||
LEFT JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=t.id
|
||||
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
|
||||
GROUP BY t.id,t.public_id,ua.id,ua.public_id`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return actor{}, ErrAccessDenied
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func normalizeInput(input *CreateInput) {
|
||||
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.Provider = strings.ToLower(strings.TrimSpace(input.Provider))
|
||||
input.BaseURL = strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
|
||||
input.ModelID = strings.TrimSpace(input.ModelID)
|
||||
input.APIKey = strings.TrimSpace(input.APIKey)
|
||||
if input.Temperature == 0 {
|
||||
input.Temperature = 0.2
|
||||
}
|
||||
if input.MaxTokens == 0 {
|
||||
input.MaxTokens = 1200
|
||||
}
|
||||
}
|
||||
|
||||
func validateInput(input CreateInput) error {
|
||||
parsed, err := url.Parse(input.BaseURL)
|
||||
validProvider := input.Provider == "ark" || input.Provider == "openai_compatible"
|
||||
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")))
|
||||
if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 {
|
||||
return ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func credentialHint(key string) string {
|
||||
runes := []rune(strings.TrimSpace(key))
|
||||
if len(runes) <= 4 {
|
||||
return "••••"
|
||||
}
|
||||
return "••••" + string(runes[len(runes)-4:])
|
||||
}
|
||||
|
||||
func normalizedErrorCode(err error) string {
|
||||
value := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(value, "401"), strings.Contains(value, "403"):
|
||||
return "AUTHENTICATION_FAILED"
|
||||
case strings.Contains(value, "429"):
|
||||
return "RATE_LIMITED"
|
||||
case strings.Contains(value, "timeout"), strings.Contains(value, "deadline"):
|
||||
return "TIMEOUT"
|
||||
case strings.Contains(value, "validation"), strings.Contains(value, "decode"):
|
||||
return "INVALID_RESPONSE"
|
||||
default:
|
||||
return "CONNECTION_FAILED"
|
||||
}
|
||||
}
|
||||
|
||||
func modelAAD(tenantID, publicID string, version int64) []byte {
|
||||
return []byte(fmt.Sprintf("content-model-credential-v1\n%s\n%s\n%d", tenantID, publicID, version))
|
||||
}
|
||||
|
||||
func validUUID(value string) bool { _, err := uuid.Parse(value); return err == nil }
|
||||
|
||||
const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id`
|
||||
const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanModel(row rowScanner) (storedModel, error) {
|
||||
var item storedModel
|
||||
err := row.Scan(&item.ID, &item.TenantID, &item.DisplayName, &item.Provider, &item.BaseURL, &item.ModelID, &item.CredentialHint, &item.Temperature, &item.MaxTokens, &item.Status, &item.TestStatus, &item.LastTestedAt, &item.LastErrorCode, &item.ActivatedAt, &item.Version, &item.CreatedAt, &item.UpdatedAt, &item.tenantInternalID, &item.encrypted, &item.nonce, &item.keyVersion)
|
||||
return item, err
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
type GenerationInput struct {
|
||||
Content string
|
||||
TenantID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type GenerationResult struct {
|
||||
@@ -89,13 +90,21 @@ func safePolicyToken(value string) string {
|
||||
}
|
||||
|
||||
type ArkGenerator struct {
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
endpoint string
|
||||
apiKey string
|
||||
model string
|
||||
provider string
|
||||
temperature float64
|
||||
maxTokens int
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewArkGenerator(baseURL, apiKey, model string, timeout time.Duration) (*ArkGenerator, error) {
|
||||
return NewCompatibleGenerator("ark", baseURL, apiKey, model, 0.2, 1200, timeout)
|
||||
}
|
||||
|
||||
func NewCompatibleGenerator(provider, baseURL, apiKey, model string, temperature float64, maxTokens int, timeout time.Duration) (*ArkGenerator, error) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
apiKey = strings.TrimSpace(apiKey)
|
||||
model = strings.TrimSpace(model)
|
||||
@@ -103,11 +112,15 @@ func NewArkGenerator(baseURL, apiKey, model string, timeout time.Duration) (*Ark
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, errors.New("Ark base URL must be an absolute HTTP(S) URL")
|
||||
}
|
||||
if apiKey == "" || model == "" || timeout <= 0 {
|
||||
return nil, errors.New("Ark API key, content model and positive timeout are required")
|
||||
if provider != "ark" && provider != "openai_compatible" {
|
||||
return nil, errors.New("content model provider must be ark or openai_compatible")
|
||||
}
|
||||
if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 {
|
||||
return nil, errors.New("content model credentials and valid generation parameters are required")
|
||||
}
|
||||
return &ArkGenerator{
|
||||
endpoint: baseURL + "/chat/completions", apiKey: apiKey, model: model,
|
||||
provider: provider, temperature: temperature, maxTokens: maxTokens,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}, nil
|
||||
}
|
||||
@@ -123,8 +136,8 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
|
||||
{"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、keywords、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,keywords是1至8个简短关键词字符串组成的数组,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"},
|
||||
{"role": "user", "content": content},
|
||||
},
|
||||
"temperature": 0.2,
|
||||
"max_tokens": 1200,
|
||||
"temperature": g.temperature,
|
||||
"max_tokens": g.maxTokens,
|
||||
"response_format": map[string]string{"type": "json_object"},
|
||||
}
|
||||
encoded, err := json.Marshal(requestBody)
|
||||
@@ -181,8 +194,8 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
|
||||
if !validGeneratedContent(result) {
|
||||
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation"))
|
||||
}
|
||||
result.ModelRef = "ark:" + g.model
|
||||
result.PromptVersion = "story-ark-json-v1"
|
||||
result.ModelRef = g.provider + ":" + g.model
|
||||
result.PromptVersion = "story-" + g.provider + "-json-v2"
|
||||
result.AllowStory = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
return outbox.Retryable(ErrConversationContentNotReady)
|
||||
}
|
||||
content := joinedTurnContent(sourceTurns)
|
||||
generated, err := p.generateSafely(ctx, content)
|
||||
generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -125,13 +125,17 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
|
||||
return storeGeneration(ctx, tx, event.AggregateID, current, generated, generationVersion)
|
||||
}
|
||||
|
||||
func (p *Processor) generateSafely(ctx context.Context, content string) (GenerationResult, error) {
|
||||
func (p *Processor) generateSafely(ctx context.Context, content string, tenantIDs ...string) (GenerationResult, error) {
|
||||
tenantID := ""
|
||||
if len(tenantIDs) > 0 {
|
||||
tenantID = tenantIDs[0]
|
||||
}
|
||||
decision, err := p.safety.Classify(ctx, content)
|
||||
if err != nil {
|
||||
return GenerationResult{}, err
|
||||
}
|
||||
if decision.AllowOrdinaryStory {
|
||||
generated, generateErr := p.generator.Generate(ctx, GenerationInput{Content: content})
|
||||
generated, generateErr := p.generator.Generate(ctx, GenerationInput{TenantID: tenantID, Content: content})
|
||||
if generateErr != nil {
|
||||
return GenerationResult{}, generateErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"data-terminal-cloud/internal/concurrency"
|
||||
"data-terminal-cloud/internal/contentmodel"
|
||||
)
|
||||
|
||||
func ListContentModelsHandler(service *contentmodel.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
|
||||
if !validUUID(tenantID) {
|
||||
writeValidation(w, r, "租户参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"})
|
||||
return
|
||||
}
|
||||
items, err := service.List(r.Context(), principal, tenantID)
|
||||
if err != nil {
|
||||
writeContentModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
WriteJSON(w, r, http.StatusOK, map[string]any{"items": items})
|
||||
})
|
||||
}
|
||||
|
||||
func CreateContentModelHandler(service *contentmodel.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := webPrincipal(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input contentmodel.CreateInput
|
||||
if !decodeWebJSON(w, r, &input, "大模型配置参数不正确") {
|
||||
return
|
||||
}
|
||||
input.TenantID = strings.TrimSpace(r.Header.Get("X-Tenant-Id"))
|
||||
item, err := service.Create(r.Context(), principal, input, RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeContentModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusCreated, item)
|
||||
})
|
||||
}
|
||||
|
||||
func TestContentModelHandler(service *contentmodel.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := resourcePrincipal(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
item, err := service.Test(r.Context(), principal, strings.TrimSpace(r.Header.Get("X-Tenant-Id")), r.PathValue("id"), RequestID(r.Context()))
|
||||
if err != nil {
|
||||
writeContentModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func ActivateContentModelHandler(service *contentmodel.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := resourcePrincipal(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version, err := concurrency.ParseIfMatch(r)
|
||||
if err != nil {
|
||||
writeETagError(w, r, err)
|
||||
return
|
||||
}
|
||||
item, err := service.Activate(r.Context(), principal, strings.TrimSpace(r.Header.Get("X-Tenant-Id")), r.PathValue("id"), RequestID(r.Context()), version)
|
||||
if err != nil {
|
||||
writeContentModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func RevokeContentModelHandler(service *contentmodel.Service) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := resourcePrincipal(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version, err := concurrency.ParseIfMatch(r)
|
||||
if err != nil {
|
||||
writeETagError(w, r, err)
|
||||
return
|
||||
}
|
||||
item, err := service.Revoke(r.Context(), principal, strings.TrimSpace(r.Header.Get("X-Tenant-Id")), r.PathValue("id"), RequestID(r.Context()), version)
|
||||
if err != nil {
|
||||
writeContentModelError(w, r, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("ETag", concurrency.FormatETag(item.Version))
|
||||
WriteJSON(w, r, http.StatusOK, item)
|
||||
})
|
||||
}
|
||||
|
||||
func writeContentModelError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, contentmodel.ErrInvalid):
|
||||
writeValidation(w, r, "大模型配置参数不正确", FieldError{Field: "model", Message: "请检查供应商、地址、模型ID、密钥和生成参数"})
|
||||
case errors.Is(err, contentmodel.ErrAccessDenied):
|
||||
WriteError(w, r, http.StatusForbidden, "CONTENT_MODEL_ACCESS_DENIED", "只有家庭负责人或管理员可以管理总结模型")
|
||||
case errors.Is(err, contentmodel.ErrNotFound):
|
||||
WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "大模型配置不存在")
|
||||
case errors.Is(err, contentmodel.ErrVersion):
|
||||
WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "配置已更新,请刷新后重试")
|
||||
case errors.Is(err, contentmodel.ErrTestRequired):
|
||||
WriteError(w, r, http.StatusConflict, "MODEL_TEST_REQUIRED", "请先通过连通性测试再激活")
|
||||
case errors.Is(err, contentmodel.ErrActiveRevoke):
|
||||
WriteError(w, r, http.StatusConflict, "ACTIVE_MODEL_CANNOT_REMOVE", "请先激活另一个模型,再移除当前模型")
|
||||
default:
|
||||
WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "大模型配置处理失败")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"data-terminal-cloud/internal/alerts"
|
||||
"data-terminal-cloud/internal/billing"
|
||||
"data-terminal-cloud/internal/buildinfo"
|
||||
"data-terminal-cloud/internal/contentmodel"
|
||||
"data-terminal-cloud/internal/device"
|
||||
"data-terminal-cloud/internal/diagnostics"
|
||||
"data-terminal-cloud/internal/health"
|
||||
@@ -42,6 +43,7 @@ type RouterOptions struct {
|
||||
DiagnosticService *diagnostics.Service
|
||||
OpsAccessService *opsaccess.Service
|
||||
OpsDeviceService *opsdevice.Service
|
||||
ContentModelService *contentmodel.Service
|
||||
ConversationCreator ConversationCreator
|
||||
ConversationActivator ConversationActivator
|
||||
DeviceConversationStateReader DeviceConversationStateReader
|
||||
@@ -164,6 +166,13 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService)))
|
||||
mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(options.OpsDeviceService)))
|
||||
}
|
||||
if options.ContentModelService != nil {
|
||||
mux.Handle("GET /api/v1/content-models", authenticated(ListContentModelsHandler(options.ContentModelService)))
|
||||
mux.Handle("POST /api/v1/content-models", authenticated(CreateContentModelHandler(options.ContentModelService)))
|
||||
mux.Handle("POST /api/v1/content-models/{id}/test", authenticated(TestContentModelHandler(options.ContentModelService)))
|
||||
mux.Handle("POST /api/v1/content-models/{id}/activate", authenticated(ActivateContentModelHandler(options.ContentModelService)))
|
||||
mux.Handle("DELETE /api/v1/content-models/{id}", authenticated(RevokeContentModelHandler(options.ContentModelService)))
|
||||
}
|
||||
mux.Handle("GET /api/v1/households", authenticated(ListHouseholdsHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/households/{id}", authenticated(GetHouseholdHandler(options.SaaSService)))
|
||||
@@ -226,6 +235,7 @@ func NewRouter(options RouterOptions) http.Handler {
|
||||
}
|
||||
mux.Handle("GET /api/v1/conversations/{id}/turns", authenticated(ListConversationTurnsHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/conversations/{id}/summaries", authenticated(ListConversationSummariesHandler(options.SaaSService)))
|
||||
mux.Handle("POST /api/v1/conversations/{id}/summaries:regenerate", authenticated(RegenerateConversationSummaryHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/subjects/{id}/stories", authenticated(ListSubjectStoriesHandler(options.SaaSService)))
|
||||
mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService)))
|
||||
mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService)))
|
||||
|
||||
@@ -1044,6 +1044,21 @@ func ListConversationSummariesHandler(service *saas.Service) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func RegenerateConversationSummaryHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := resourcePrincipal(writer, request, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
version, err := service.RegenerateConversationSummary(request.Context(), principal, request.PathValue("id"), RequestID(request.Context()))
|
||||
if err != nil {
|
||||
writeSaaSError(writer, request, err)
|
||||
return
|
||||
}
|
||||
WriteJSON(writer, request, http.StatusAccepted, map[string]any{"generation_version": version, "status": "queued"})
|
||||
})
|
||||
}
|
||||
|
||||
func ListSubjectStoriesHandler(service *saas.Service) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
principal, ok := resourcePrincipal(writer, request, "id")
|
||||
@@ -1921,6 +1936,8 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error
|
||||
WriteError(writer, request, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT", "当前故事审核状态不允许此操作")
|
||||
case errors.Is(err, saas.ErrFactReviewStateConflict):
|
||||
WriteError(writer, request, http.StatusConflict, "FACT_REVIEW_STATE_CONFLICT", "当前事实候选状态不允许此操作")
|
||||
case errors.Is(err, saas.ErrConversationSummaryNotReady):
|
||||
WriteError(writer, request, http.StatusConflict, "CONVERSATION_SUMMARY_NOT_READY", "聊天尚未结束或没有可用于总结的文字内容")
|
||||
case errors.Is(err, pagination.ErrInvalidCursor):
|
||||
writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"})
|
||||
default:
|
||||
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
CapabilityRecordingDownload Capability = "recording.download"
|
||||
CapabilityPrivacyRead Capability = "privacy.read"
|
||||
CapabilityPrivacyManage Capability = "privacy.manage"
|
||||
CapabilityModelManage Capability = "model.manage"
|
||||
)
|
||||
|
||||
var roleCapabilities = map[Role]map[Capability]struct{}{
|
||||
@@ -35,12 +36,14 @@ var roleCapabilities = map[Role]map[Capability]struct{}{
|
||||
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
|
||||
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
|
||||
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
|
||||
CapabilityModelManage,
|
||||
),
|
||||
RoleAdmin: capabilitySet(
|
||||
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
|
||||
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
|
||||
CapabilityStoryReview, CapabilityDeviceManage,
|
||||
CapabilityFirmwareManage, CapabilityPrivacyRead,
|
||||
CapabilityModelManage,
|
||||
),
|
||||
RoleEditor: capabilitySet(
|
||||
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
|
||||
|
||||
@@ -24,6 +24,7 @@ var (
|
||||
ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required")
|
||||
ErrReviewStateConflict = errors.New("content review state does not allow this operation")
|
||||
ErrFactReviewStateConflict = errors.New("fact candidate review state does not allow this operation")
|
||||
ErrConversationSummaryNotReady = errors.New("conversation is not ready for summary generation")
|
||||
)
|
||||
|
||||
type ConversationTurn struct {
|
||||
@@ -216,6 +217,35 @@ func (s *Service) ListConversationContentSummaries(ctx context.Context, principa
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) RegenerateConversationSummary(ctx context.Context, principal identity.Principal, conversationPublicID, requestID string) (int64, error) {
|
||||
var generationVersion int64
|
||||
err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
access, conversationID, err := s.resolveConversationContent(ctx, tx, principal, conversationPublicID, identity.CapabilityStoryReview, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var status string
|
||||
var maxSequence int64
|
||||
if err := tx.QueryRow(ctx, `SELECT c.status,(SELECT COALESCE(MAX(sequence_no),0) FROM conversation_turn ct WHERE ct.conversation_id=c.id AND ct.tenant_id=c.tenant_id) FROM conversation c WHERE c.id=$1::uuid`, conversationID).Scan(&status, &maxSequence); err != nil {
|
||||
return err
|
||||
}
|
||||
if (status != "COMPLETED" && status != "FAILED") || maxSequence == 0 {
|
||||
return ErrConversationSummaryNotReady
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `SELECT GREATEST($2::bigint,COALESCE(MAX(generation_version),0))+1 FROM conversation_summary WHERE tenant_id=$1::uuid AND conversation_id=$3::uuid`, access.tenantID, maxSequence, conversationID).Scan(&generationVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"generation_version": generationVersion, "reason": "manual_regeneration"})
|
||||
dedupe := fmt.Sprintf("content-regenerate:%s:%d", conversationID, generationVersion)
|
||||
if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "conversation", AggregateID: conversationID, EventType: "conversation.content_extraction_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil {
|
||||
return err
|
||||
}
|
||||
resourceID := conversationPublicID
|
||||
return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "conversation.summary_regenerate", ResourceType: "conversation", ResourceID: &resourceID, RequestID: requestID, Purpose: "使用当前激活的大模型重新总结聊天", Result: "queued", Detail: map[string]any{"generation_version": generationVersion}})
|
||||
})
|
||||
return generationVersion, err
|
||||
}
|
||||
|
||||
func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Principal, subjectPublicID, status string) ([]Story, error) {
|
||||
access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user