feat(server): meter AI point usage across tenant AI features
Adds an ai_point_usage_logs ledger and a reserve/refund/complete pipeline that charges AI points for article selection optimize, template analyze /title/outline, and KOL prompt generate/optimize. Pending reservations are reconciled when the kol-assist worker and template-assist tasks finish or fail, so points refund automatically on errors. Workspace now exposes the AI quota status and a paginated usage ledger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,10 @@ import (
|
||||
"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"
|
||||
@@ -48,15 +50,18 @@ type KolAssistTaskResponse struct {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -65,6 +70,7 @@ func NewKolAssistService(
|
||||
logger *zap.Logger,
|
||||
) *KolAssistService {
|
||||
return &KolAssistService{
|
||||
pool: pool,
|
||||
profileSvc: profileSvc,
|
||||
repo: repo,
|
||||
llm: llmClient,
|
||||
@@ -74,6 +80,11 @@ func NewKolAssistService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *KolAssistService) WithCache(c sharedcache.Cache) *KolAssistService {
|
||||
s.cache = c
|
||||
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 {
|
||||
@@ -94,6 +105,11 @@ func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req Ass
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -103,6 +119,7 @@ func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req Ass
|
||||
TaskType: taskType,
|
||||
RequestJSON: requestJSON,
|
||||
}); err != nil {
|
||||
s.refundReservedAIPoints(context.Background(), actor, reservation, err.Error())
|
||||
return "", response.ErrInternal(50084, "kol_assist_create_failed", err.Error())
|
||||
}
|
||||
|
||||
@@ -111,6 +128,7 @@ func (s *KolAssistService) Submit(ctx context.Context, actor auth.Actor, req Ass
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -184,3 +202,75 @@ func normalizeAssistRequest(req AssistRequest) (AssistRequest, string, 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"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user