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:
@@ -11,6 +11,7 @@ import (
|
||||
"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/llm"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
@@ -38,6 +39,7 @@ type ArticleSelectionOptimizeService struct {
|
||||
pool *pgxpool.Pool
|
||||
llm llm.Client
|
||||
defaultMaxOutTokens int64
|
||||
cache sharedcache.Cache
|
||||
}
|
||||
|
||||
func NewArticleSelectionOptimizeService(
|
||||
@@ -52,6 +54,11 @@ func NewArticleSelectionOptimizeService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) WithCache(c sharedcache.Cache) *ArticleSelectionOptimizeService {
|
||||
s.cache = c
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) ValidateRequest(
|
||||
ctx context.Context,
|
||||
articleID int64,
|
||||
@@ -70,9 +77,29 @@ func (s *ArticleSelectionOptimizeService) ValidateRequest(
|
||||
|
||||
func (s *ArticleSelectionOptimizeService) OptimizeSelection(
|
||||
ctx context.Context,
|
||||
articleID int64,
|
||||
req ArticleSelectionOptimizeRequest,
|
||||
onDelta func(string),
|
||||
) (*ArticleSelectionOptimizeResult, error) {
|
||||
actor := auth.MustActor(ctx)
|
||||
resourceType := "article"
|
||||
reservation, err := ReserveAIPoints(ctx, s.pool, s.cache, AIPointReserveInput{
|
||||
TenantID: actor.TenantID,
|
||||
OperatorID: actor.UserID,
|
||||
UsageType: AIUsageTypeArticleSelectionOptimize,
|
||||
ResourceType: &resourceType,
|
||||
ResourceID: &articleID,
|
||||
MeteredText: req.SelectedText,
|
||||
Metadata: map[string]any{
|
||||
"article_id": articleID,
|
||||
"instruction": strings.TrimSpace(req.Instruction),
|
||||
"title_length": countAIPointChars(req.Title),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result, err := s.llm.Generate(
|
||||
ctx,
|
||||
llm.GenerateRequest{
|
||||
@@ -83,14 +110,27 @@ func (s *ArticleSelectionOptimizeService) OptimizeSelection(
|
||||
onDelta,
|
||||
)
|
||||
if err != nil {
|
||||
refundCtx, cancel := newGenerationCleanupContext()
|
||||
_ = RefundAIPoints(refundCtx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, err.Error())
|
||||
cancel()
|
||||
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", err.Error())
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(result.Content)
|
||||
if content == "" {
|
||||
refundCtx, cancel := newGenerationCleanupContext()
|
||||
_ = RefundAIPoints(refundCtx, s.pool, s.cache, actor.TenantID, actor.UserID, *reservation, "optimized content is empty")
|
||||
cancel()
|
||||
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "optimized content is empty")
|
||||
}
|
||||
|
||||
completeCtx, cancel := newGenerationCleanupContext()
|
||||
if err := CompleteAIPoints(completeCtx, s.pool, actor.TenantID, *reservation, result.Model); err != nil {
|
||||
cancel()
|
||||
return nil, response.ErrInternal(50019, "article_selection_optimize_failed", "failed to confirm ai points usage")
|
||||
}
|
||||
cancel()
|
||||
|
||||
return &ArticleSelectionOptimizeResult{
|
||||
Content: content,
|
||||
Model: result.Model,
|
||||
|
||||
Reference in New Issue
Block a user