618399f86d
- Replace single Load() with a watching Store that re-reads config files
(and config.local.yaml) and fans out a ReloadEvent with a per-field diff
so consumers can decide whether the change is hot-applicable or requires
a process restart.
- Wrap llm, retrieval, vector store, and object storage clients in
Reloadable* shells so the bootstrap can swap their underlying impls when
config changes without re-instantiating handlers.
- Make jwt.Manager and ops TokenIssuer mutable under a lock so secrets and
TTLs can be rotated live; thread default plan code through a setter on
the ops AdminUserService.
- Wire ConfigStore through bootstrap and every cmd/main.go, scheduler /
worker / tenant-api / ops-api start the watcher; services and handlers
take a config.Provider so they always read current values for things
like generation.stream_enabled, scheduler dispatch, retrieval, etc.
- Switch shared/config decoding off viper to a Kratos-derived runtime
package so env placeholders (\${VAR:default}) resolve consistently and
the same source machinery powers both the loader and the watcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
284 lines
8.3 KiB
Go
284 lines
8.3 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
|
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
const (
|
|
kolAssistModeGenerate = "generate"
|
|
kolAssistModeOptimize = "optimize"
|
|
)
|
|
|
|
var ErrKolAssistTaskNotFound = response.ErrNotFound(40463, "kol_assist_task_not_found", "assist task not found")
|
|
|
|
type AssistRequest struct {
|
|
Mode string `json:"mode" binding:"required"`
|
|
Description string `json:"description"`
|
|
CurrentContent string `json:"current_content"`
|
|
PromptID *int64 `json:"prompt_id"`
|
|
Schema *KolSchemaJSON `json:"schema"`
|
|
}
|
|
|
|
type KolAssistSubmitResponse struct {
|
|
TaskID string `json:"task_id"`
|
|
}
|
|
|
|
type KolAssistResult struct {
|
|
Content string `json:"content"`
|
|
Schema KolSchemaJSON `json:"schema"`
|
|
}
|
|
|
|
type KolAssistTaskResponse struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Result *KolAssistResult `json:"result,omitempty"`
|
|
ErrorMessage *string `json:"error_message,omitempty"`
|
|
}
|
|
|
|
type KolAssistService struct {
|
|
pool *pgxpool.Pool
|
|
profileSvc *KolProfileService
|
|
repo repository.KolAssistRepository
|
|
llm llm.Client
|
|
messaging *rabbitmq.Client
|
|
logger *zap.Logger
|
|
urlResolver *kolAssistURLResolver
|
|
cache sharedcache.Cache
|
|
}
|
|
|
|
func NewKolAssistService(
|
|
pool *pgxpool.Pool,
|
|
profileSvc *KolProfileService,
|
|
repo repository.KolAssistRepository,
|
|
llmClient llm.Client,
|
|
messagingClient *rabbitmq.Client,
|
|
llmCfg config.LLMConfig,
|
|
logger *zap.Logger,
|
|
) *KolAssistService {
|
|
return &KolAssistService{
|
|
pool: pool,
|
|
profileSvc: profileSvc,
|
|
repo: repo,
|
|
llm: llmClient,
|
|
messaging: messagingClient,
|
|
logger: logger,
|
|
urlResolver: newKolAssistURLResolver(llmCfg, logger),
|
|
}
|
|
}
|
|
|
|
func (s *KolAssistService) WithCache(c sharedcache.Cache) *KolAssistService {
|
|
s.cache = c
|
|
return s
|
|
}
|
|
|
|
func (s *KolAssistService) WithConfigProvider(provider runtimeConfigProvider) *KolAssistService {
|
|
if s != nil && s.urlResolver != nil {
|
|
s.urlResolver.withConfigProvider(provider)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req AssistRequest) (string, error) {
|
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := s.llm.Validate(); err != nil {
|
|
return "", response.ErrServiceUnavailable(50341, "llm_unavailable", err.Error())
|
|
}
|
|
|
|
req, taskType, err := normalizeAssistRequest(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
requestJSON, err := json.Marshal(req)
|
|
if err != nil {
|
|
return "", response.ErrInternal(50083, "kol_assist_marshal_failed", "failed to encode assist request")
|
|
}
|
|
|
|
taskID := uuid.NewString()
|
|
reservation, err := s.reserveAIPoints(ctx, actor, taskID, req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := s.repo.Create(ctx, repository.CreateKolAssistInput{
|
|
ID: taskID,
|
|
TenantID: profile.TenantID,
|
|
KolProfileID: profile.ID,
|
|
PromptID: req.PromptID,
|
|
OperatorID: actor.UserID,
|
|
TaskType: taskType,
|
|
RequestJSON: requestJSON,
|
|
}); err != nil {
|
|
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
|
|
return "", response.ErrInternal(50084, "kol_assist_create_failed", err.Error())
|
|
}
|
|
|
|
if err := publishKolAssistTask(ctx, s.messaging, kolAssistJob{
|
|
TaskID: taskID,
|
|
TenantID: profile.TenantID,
|
|
}); err != nil {
|
|
_ = s.repo.MarkFailed(context.Background(), profile.TenantID, taskID, err.Error())
|
|
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
|
|
return "", response.ErrServiceUnavailable(50342, "kol_assist_queue_unavailable", "assist queue is busy, please retry")
|
|
}
|
|
|
|
return taskID, nil
|
|
}
|
|
|
|
func (s *KolAssistService) Get(ctx context.Context, actor auth.Actor, id string) (*repository.KolAssistTask, error) {
|
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
task, err := s.repo.Get(ctx, profile.TenantID, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return nil, response.ErrInternal(50085, "kol_assist_query_failed", err.Error())
|
|
}
|
|
if task == nil {
|
|
return nil, ErrKolAssistTaskNotFound
|
|
}
|
|
return task, nil
|
|
}
|
|
|
|
func NewKolAssistTaskResponse(task *repository.KolAssistTask) (*KolAssistTaskResponse, error) {
|
|
if task == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
resp := &KolAssistTaskResponse{
|
|
ID: task.ID,
|
|
Status: task.Status,
|
|
ErrorMessage: task.ErrorMessage,
|
|
}
|
|
if len(task.ResultJSON) == 0 {
|
|
return resp, nil
|
|
}
|
|
|
|
var result KolAssistResult
|
|
if err := json.Unmarshal(task.ResultJSON, &result); err != nil {
|
|
return nil, response.ErrInternal(50086, "kol_assist_result_invalid", err.Error())
|
|
}
|
|
if result.Schema.Variables == nil {
|
|
result.Schema.Variables = []KolVariableDefinition{}
|
|
}
|
|
resp.Result = &result
|
|
return resp, nil
|
|
}
|
|
|
|
func normalizeAssistRequest(req AssistRequest) (AssistRequest, string, error) {
|
|
req.Mode = strings.ToLower(strings.TrimSpace(req.Mode))
|
|
req.Description = strings.TrimSpace(req.Description)
|
|
req.CurrentContent = strings.TrimSpace(req.CurrentContent)
|
|
|
|
switch req.Mode {
|
|
case kolAssistModeGenerate:
|
|
if req.Description == "" {
|
|
return AssistRequest{}, "", response.ErrBadRequest(40067, "kol_assist_description_required", "description is required")
|
|
}
|
|
case kolAssistModeOptimize:
|
|
if req.CurrentContent == "" {
|
|
return AssistRequest{}, "", response.ErrBadRequest(40068, "kol_assist_current_content_required", "current_content is required")
|
|
}
|
|
default:
|
|
return AssistRequest{}, "", response.ErrBadRequest(40069, "kol_assist_mode_invalid", "mode must be generate or optimize")
|
|
}
|
|
|
|
if req.Schema != nil {
|
|
if err := ValidateSchema(*req.Schema); err != nil {
|
|
return AssistRequest{}, "", response.ErrBadRequest(40070, "kol_assist_schema_invalid", err.Error())
|
|
}
|
|
}
|
|
|
|
return req, "ai_" + req.Mode, nil
|
|
}
|
|
|
|
func (s *KolAssistService) reserveAIPoints(
|
|
ctx context.Context,
|
|
actor auth.Actor,
|
|
taskID string,
|
|
req AssistRequest,
|
|
) (*AIPointReservation, error) {
|
|
usageType := AIUsageTypeKolPromptGenerate
|
|
if req.Mode == kolAssistModeOptimize {
|
|
usageType = AIUsageTypeKolPromptOptimize
|
|
}
|
|
resourceType := "kol_assist_task"
|
|
resourceUID := taskID
|
|
var resourceID *int64
|
|
if req.PromptID != nil && *req.PromptID > 0 {
|
|
resourceID = req.PromptID
|
|
}
|
|
return ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
|
|
TenantID: actor.TenantID,
|
|
OperatorID: actor.UserID,
|
|
UsageType: usageType,
|
|
ResourceType: &resourceType,
|
|
ResourceID: resourceID,
|
|
ResourceUID: &resourceUID,
|
|
MeteredText: buildKolAssistAIPointMeteredText(req),
|
|
Metadata: map[string]any{
|
|
"task_id": taskID,
|
|
"mode": req.Mode,
|
|
"prompt_id": req.PromptID,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *KolAssistService) reserveStreamingAIPoints(ctx context.Context, actor auth.Actor, req AssistRequest) (*AIPointReservation, error) {
|
|
usageType := AIUsageTypeKolPromptGenerate
|
|
if req.Mode == kolAssistModeOptimize {
|
|
usageType = AIUsageTypeKolPromptOptimize
|
|
}
|
|
resourceType := "kol_prompt"
|
|
var resourceID *int64
|
|
if req.PromptID != nil && *req.PromptID > 0 {
|
|
resourceID = req.PromptID
|
|
}
|
|
return ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
|
|
TenantID: actor.TenantID,
|
|
OperatorID: actor.UserID,
|
|
UsageType: usageType,
|
|
ResourceType: &resourceType,
|
|
ResourceID: resourceID,
|
|
MeteredText: buildKolAssistAIPointMeteredText(req),
|
|
Metadata: map[string]any{
|
|
"mode": req.Mode,
|
|
"prompt_id": req.PromptID,
|
|
"stream": true,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *KolAssistService) refundReservedAIPoints(ctx context.Context, actor auth.Actor, reservation *AIPointReservation, errorMessage string) {
|
|
if reservation == nil {
|
|
return
|
|
}
|
|
_ = RefundAIPoints(ctx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, errorMessage)
|
|
}
|
|
|
|
func buildKolAssistAIPointMeteredText(req AssistRequest) string {
|
|
parts := []string{req.Description}
|
|
if req.Mode == kolAssistModeOptimize {
|
|
parts = append(parts, req.CurrentContent)
|
|
}
|
|
return strings.TrimSpace(strings.Join(parts, "\n"))
|
|
}
|