feat(kol): KOL workspace services (profile/package/prompt/asset/renderer)
This commit is contained in:
@@ -0,0 +1,319 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreateKolPackageInput struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
CoverURL *string `json:"cover_url"`
|
||||||
|
Industry *string `json:"industry"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
PriceNote *string `json:"price_note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateKolPackageInput = CreateKolPackageInput
|
||||||
|
|
||||||
|
type KolPackageResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
KolProfileID int64 `json:"kol_profile_id"`
|
||||||
|
KolDisplayName string `json:"kol_display_name"`
|
||||||
|
KolAvatarURL *string `json:"kol_avatar_url"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
CoverURL *string `json:"cover_url"`
|
||||||
|
Industry *string `json:"industry"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
PriceNote *string `json:"price_note"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
PromptCount int `json:"prompt_count"`
|
||||||
|
SubscriberCount int `json:"subscriber_count"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KolPackageService struct {
|
||||||
|
profileSvc *KolProfileService
|
||||||
|
repo repository.KolPackageRepository
|
||||||
|
promptRepo repository.KolPromptRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKolPackageService(
|
||||||
|
profileSvc *KolProfileService,
|
||||||
|
repo repository.KolPackageRepository,
|
||||||
|
promptRepo repository.KolPromptRepository,
|
||||||
|
) *KolPackageService {
|
||||||
|
return &KolPackageService{
|
||||||
|
profileSvc: profileSvc,
|
||||||
|
repo: repo,
|
||||||
|
promptRepo: promptRepo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) List(ctx context.Context, actor auth.Actor) ([]KolPackageResponse, error) {
|
||||||
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, err := s.repo.ListByProfile(ctx, actor.TenantID, profile.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]KolPackageResponse, 0, len(packages))
|
||||||
|
for _, pkg := range packages {
|
||||||
|
item, err := s.buildPackageResponse(ctx, profile, &pkg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, *item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) Get(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) {
|
||||||
|
profile, pkg, err := s.loadOwnedPackage(ctx, actor, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.buildPackageResponse(ctx, profile, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) Create(ctx context.Context, actor auth.Actor, input CreateKolPackageInput) (*KolPackageResponse, error) {
|
||||||
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, response.ErrBadRequest(40061, "kol_package_name_required", "package name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsJSON, err := json.Marshal(normalizeKolStringList(input.Tags))
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrBadRequest(40062, "kol_package_tags_invalid", "tags must be valid json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.repo.Create(ctx, repository.CreateKolPackageInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
KolProfileID: profile.ID,
|
||||||
|
Name: name,
|
||||||
|
Description: normalizeKolOptionalString(input.Description),
|
||||||
|
CoverURL: normalizeKolOptionalString(input.CoverURL),
|
||||||
|
Industry: normalizeKolOptionalString(input.Industry),
|
||||||
|
Tags: tagsJSON,
|
||||||
|
PriceNote: normalizeKolOptionalString(input.PriceNote),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50063, "kol_package_create_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPackageResponse(ctx, profile, pkg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) Update(ctx context.Context, actor auth.Actor, id int64, input UpdateKolPackageInput) (*KolPackageResponse, error) {
|
||||||
|
profile, _, err := s.loadOwnedPackage(ctx, actor, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, response.ErrBadRequest(40061, "kol_package_name_required", "package name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsJSON, err := json.Marshal(normalizeKolStringList(input.Tags))
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrBadRequest(40062, "kol_package_tags_invalid", "tags must be valid json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.Update(ctx, actor.TenantID, repository.UpdateKolPackageInput{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
Description: normalizeKolOptionalString(input.Description),
|
||||||
|
CoverURL: normalizeKolOptionalString(input.CoverURL),
|
||||||
|
Industry: normalizeKolOptionalString(input.Industry),
|
||||||
|
Tags: tagsJSON,
|
||||||
|
PriceNote: normalizeKolOptionalString(input.PriceNote),
|
||||||
|
}); err != nil {
|
||||||
|
return nil, response.ErrInternal(50064, "kol_package_update_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.repo.GetByID(ctx, actor.TenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return nil, errKolPackageNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPackageResponse(ctx, profile, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) Publish(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) {
|
||||||
|
profile, _, err := s.loadOwnedPackage(ctx, actor, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateStatus(ctx, actor.TenantID, id, "published"); err != nil {
|
||||||
|
return nil, response.ErrInternal(50065, "kol_package_publish_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.repo.GetByID(ctx, actor.TenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return nil, errKolPackageNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPackageResponse(ctx, profile, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) Archive(ctx context.Context, actor auth.Actor, id int64) (*KolPackageResponse, error) {
|
||||||
|
profile, _, err := s.loadOwnedPackage(ctx, actor, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.UpdateStatus(ctx, actor.TenantID, id, "archived"); err != nil {
|
||||||
|
return nil, response.ErrInternal(50066, "kol_package_archive_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.repo.GetByID(ctx, actor.TenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return nil, errKolPackageNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPackageResponse(ctx, profile, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) Delete(ctx context.Context, actor auth.Actor, id int64) error {
|
||||||
|
if _, _, err := s.loadOwnedPackage(ctx, actor, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.SoftDelete(ctx, actor.TenantID, id); err != nil {
|
||||||
|
return response.ErrInternal(50067, "kol_package_delete_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) loadOwnedPackage(ctx context.Context, actor auth.Actor, id int64) (*repository.KolProfile, *repository.KolPackage, error) {
|
||||||
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.repo.GetByID(ctx, actor.TenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if pkg == nil {
|
||||||
|
return nil, nil, errKolPackageNotFound
|
||||||
|
}
|
||||||
|
if pkg.KolProfileID != profile.ID {
|
||||||
|
return nil, nil, ErrPackageNotOwned
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile, pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPackageService) buildPackageResponse(
|
||||||
|
ctx context.Context,
|
||||||
|
profile *repository.KolProfile,
|
||||||
|
pkg *repository.KolPackage,
|
||||||
|
) (*KolPackageResponse, error) {
|
||||||
|
tags, err := decodeKolStringList(pkg.Tags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50068, "kol_package_decode_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
promptCount := 0
|
||||||
|
if s.promptRepo != nil {
|
||||||
|
prompts, err := s.promptRepo.ListByPackage(ctx, pkg.TenantID, pkg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50069, "kol_prompt_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
promptCount = len(prompts)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KolPackageResponse{
|
||||||
|
ID: pkg.ID,
|
||||||
|
KolProfileID: pkg.KolProfileID,
|
||||||
|
KolDisplayName: profile.DisplayName,
|
||||||
|
KolAvatarURL: profile.AvatarURL,
|
||||||
|
Name: pkg.Name,
|
||||||
|
Description: pkg.Description,
|
||||||
|
CoverURL: pkg.CoverURL,
|
||||||
|
Industry: pkg.Industry,
|
||||||
|
Tags: tags,
|
||||||
|
PriceNote: pkg.PriceNote,
|
||||||
|
Status: pkg.Status,
|
||||||
|
PromptCount: promptCount,
|
||||||
|
SubscriberCount: 0,
|
||||||
|
CreatedAt: pkg.CreatedAt,
|
||||||
|
UpdatedAt: pkg.UpdatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKolOptionalString(value *string) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
trimmed := strings.TrimSpace(*value)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKolStringList(values []string) []string {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
seen := make(map[string]struct{}, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[trimmed]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKolStringList(raw []byte) ([]string, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var values []string
|
||||||
|
if err := json.Unmarshal(raw, &values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalizeKolStringList(values), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotActiveKol = response.ErrForbidden(40341, "kol_not_active", "user is not an active KOL")
|
||||||
|
|
||||||
|
type KolProfileService struct {
|
||||||
|
repo repository.KolProfileRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
type KolProfileResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
TenantID int64 `json:"tenant_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Bio *string `json:"bio"`
|
||||||
|
AvatarURL *string `json:"avatar_url"`
|
||||||
|
MarketEnabled bool `json:"market_enabled"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKolProfileService(repo repository.KolProfileRepository) *KolProfileService {
|
||||||
|
return &KolProfileService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolProfileService) RequireActiveKol(ctx context.Context, actor auth.Actor) (*repository.KolProfile, error) {
|
||||||
|
profile, err := s.repo.GetByUser(ctx, actor.TenantID, actor.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50061, "kol_profile_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if profile == nil || profile.Status != "active" {
|
||||||
|
return nil, ErrNotActiveKol
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKolProfileResponse(profile *repository.KolProfile) *KolProfileResponse {
|
||||||
|
if profile == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KolProfileResponse{
|
||||||
|
ID: profile.ID,
|
||||||
|
TenantID: profile.TenantID,
|
||||||
|
UserID: profile.UserID,
|
||||||
|
DisplayName: profile.DisplayName,
|
||||||
|
Bio: profile.Bio,
|
||||||
|
AvatarURL: profile.AvatarURL,
|
||||||
|
MarketEnabled: profile.MarketEnabled,
|
||||||
|
Status: profile.Status,
|
||||||
|
CreatedAt: profile.CreatedAt,
|
||||||
|
UpdatedAt: profile.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KolPromptAsset struct {
|
||||||
|
storage objectstorage.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKolPromptAsset(storage objectstorage.Client) *KolPromptAsset {
|
||||||
|
return &KolPromptAsset{storage: storage}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *KolPromptAsset) assetKey(tenantID, promptID int64, revisionNo int) string {
|
||||||
|
return fmt.Sprintf("kol-prompts/%d/%d/%d.txt", tenantID, promptID, revisionNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *KolPromptAsset) Put(ctx context.Context, tenantID, promptID int64, revisionNo int, content string) (string, error) {
|
||||||
|
key := a.assetKey(tenantID, promptID, revisionNo)
|
||||||
|
if err := a.storage.PutBytes(ctx, key, []byte(content), "text/plain; charset=utf-8"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *KolPromptAsset) Get(ctx context.Context, assetKey string) (string, error) {
|
||||||
|
content, err := a.storage.GetBytes(ctx, assetKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errKolPackageNotFound = response.ErrNotFound(40461, "kol_package_not_found", "package not found")
|
||||||
|
ErrPackageNotOwned = response.ErrForbidden(40361, "kol_package_not_owned", "package is not owned by current KOL")
|
||||||
|
ErrPromptNotFound = response.ErrNotFound(40462, "kol_prompt_not_found", "prompt not found")
|
||||||
|
ErrPromptNotOwned = response.ErrForbidden(40362, "kol_prompt_not_owned", "prompt is not owned by current KOL")
|
||||||
|
errKolPromptDraftEmpty = response.ErrConflict(40961, "kol_prompt_draft_missing", "no draft revision to publish")
|
||||||
|
)
|
||||||
|
|
||||||
|
type CreatePromptInput struct {
|
||||||
|
PackageID int64 `json:"-"`
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
PlatformHint *string `json:"platform_hint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateKolPromptInput struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
PlatformHint *string `json:"platform_hint"`
|
||||||
|
SortOrder *int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveDraftInput struct {
|
||||||
|
PromptID int64 `json:"-"`
|
||||||
|
Content string `json:"content" binding:"required"`
|
||||||
|
Schema KolSchemaJSON `json:"schema" binding:"required"`
|
||||||
|
CardConfig map[string]interface{} `json:"card_config"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KolPromptDetailResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
PackageID int64 `json:"package_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
PlatformHint *string `json:"platform_hint"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
PublishedRevisionNo *int `json:"published_revision_no"`
|
||||||
|
DraftRevisionNo *int `json:"draft_revision_no"`
|
||||||
|
LatestSchemaJSON *KolSchemaJSON `json:"latest_schema_json"`
|
||||||
|
LatestCardConfigJSON map[string]interface{} `json:"latest_card_config_json"`
|
||||||
|
LatestPromptContent *string `json:"latest_prompt_content"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KolPromptRevisionResponse struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
PromptID int64 `json:"prompt_id"`
|
||||||
|
RevisionNo int `json:"revision_no"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KolPromptService struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
profileSvc *KolProfileService
|
||||||
|
pkgRepo repository.KolPackageRepository
|
||||||
|
promptRepo repository.KolPromptRepository
|
||||||
|
asset *KolPromptAsset
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKolPromptService(
|
||||||
|
pool *pgxpool.Pool,
|
||||||
|
profileSvc *KolProfileService,
|
||||||
|
pkgRepo repository.KolPackageRepository,
|
||||||
|
promptRepo repository.KolPromptRepository,
|
||||||
|
asset *KolPromptAsset,
|
||||||
|
) *KolPromptService {
|
||||||
|
return &KolPromptService{
|
||||||
|
pool: pool,
|
||||||
|
profileSvc: profileSvc,
|
||||||
|
pkgRepo: pkgRepo,
|
||||||
|
promptRepo: promptRepo,
|
||||||
|
asset: asset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) CreatePrompt(ctx context.Context, actor auth.Actor, input CreatePromptInput) (*KolPromptDetailResponse, error) {
|
||||||
|
if strings.TrimSpace(input.Name) == "" {
|
||||||
|
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, _, err := s.loadOwnedPackage(ctx, actor, input.PackageID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt, err := s.promptRepo.Create(ctx, repository.CreateKolPromptInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
PackageID: input.PackageID,
|
||||||
|
Name: strings.TrimSpace(input.Name),
|
||||||
|
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
|
||||||
|
SortOrder: 0,
|
||||||
|
CreatedBy: actor.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50070, "kol_prompt_create_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPromptDetail(ctx, prompt, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) ListByPackage(ctx context.Context, actor auth.Actor, packageID int64) ([]KolPromptDetailResponse, error) {
|
||||||
|
if _, _, err := s.loadOwnedPackage(ctx, actor, packageID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prompts, err := s.promptRepo.ListByPackage(ctx, actor.TenantID, packageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]KolPromptDetailResponse, 0, len(prompts))
|
||||||
|
for _, prompt := range prompts {
|
||||||
|
item, err := s.buildPromptDetail(ctx, &prompt, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, *item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) GetForOwner(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) {
|
||||||
|
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPromptDetail(ctx, prompt, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) UpdateMetadata(ctx context.Context, actor auth.Actor, promptID int64, input UpdateKolPromptInput) (*KolPromptDetailResponse, error) {
|
||||||
|
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(input.Name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
sortOrder := prompt.SortOrder
|
||||||
|
if input.SortOrder != nil {
|
||||||
|
sortOrder = *input.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.promptRepo.UpdateMetadata(ctx, repository.UpdateKolPromptMetadataInput{
|
||||||
|
ID: promptID,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
Name: name,
|
||||||
|
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
UpdatedBy: actor.UserID,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, response.ErrInternal(50072, "kol_prompt_update_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return nil, ErrPromptNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.buildPromptDetail(ctx, updated, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, input SaveDraftInput) (*KolPromptRevisionResponse, error) {
|
||||||
|
if strings.TrimSpace(input.Content) == "" {
|
||||||
|
return nil, response.ErrBadRequest(40064, "kol_prompt_content_required", "prompt content is required")
|
||||||
|
}
|
||||||
|
if err := ValidateSchema(input.Schema); err != nil {
|
||||||
|
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, input.PromptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRevisionNo, err := s.promptRepo.NextRevisionNo(ctx, actor.TenantID, input.PromptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50073, "kol_prompt_revision_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
assetKey, err := s.asset.Put(ctx, actor.TenantID, input.PromptID, nextRevisionNo, input.Content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50074, "kol_prompt_asset_store_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaJSON, err := JSONEncodeSchema(input.Schema)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
cardConfigJSON, err := json.Marshal(normalizeKolCardConfig(input.CardConfig))
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrBadRequest(40066, "kol_prompt_card_config_invalid", "card_config must be valid json")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50075, "kol_prompt_draft_tx_failed", "failed to begin prompt draft transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
promptTx := repository.NewKolPromptRepository(tx)
|
||||||
|
revision, err := promptTx.CreateRevision(ctx, repository.CreateKolPromptRevisionInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
PromptID: prompt.ID,
|
||||||
|
RevisionNo: nextRevisionNo,
|
||||||
|
PromptAssetKey: assetKey,
|
||||||
|
SchemaJSON: schemaJSON,
|
||||||
|
CardConfigJSON: cardConfigJSON,
|
||||||
|
CreatedBy: actor.UserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50076, "kol_prompt_draft_create_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := promptTx.SetDraftRevision(ctx, actor.TenantID, prompt.ID, nextRevisionNo, actor.UserID); err != nil {
|
||||||
|
return nil, response.ErrInternal(50077, "kol_prompt_draft_update_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, response.ErrInternal(50078, "kol_prompt_draft_commit_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return &KolPromptRevisionResponse{
|
||||||
|
ID: revision.ID,
|
||||||
|
PromptID: revision.PromptID,
|
||||||
|
RevisionNo: revision.RevisionNo,
|
||||||
|
CreatedAt: revision.CreatedAt,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64) error {
|
||||||
|
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if prompt.DraftRevisionNo == nil {
|
||||||
|
return errKolPromptDraftEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return response.ErrInternal(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
if err := repository.NewKolPromptRepository(tx).SetPublishedRevision(ctx, actor.TenantID, promptID, *prompt.DraftRevisionNo, actor.UserID); err != nil {
|
||||||
|
return response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return response.ErrInternal(50081, "kol_prompt_publish_commit_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) Delete(ctx context.Context, actor auth.Actor, promptID int64) error {
|
||||||
|
if _, _, _, err := s.loadOwnedPrompt(ctx, actor, promptID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.promptRepo.SoftDelete(ctx, actor.TenantID, promptID); err != nil {
|
||||||
|
return response.ErrInternal(50082, "kol_prompt_delete_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) loadOwnedPackage(ctx context.Context, actor auth.Actor, packageID int64) (*repository.KolProfile, *repository.KolPackage, error) {
|
||||||
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, packageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if pkg == nil {
|
||||||
|
return nil, nil, errKolPackageNotFound
|
||||||
|
}
|
||||||
|
if pkg.KolProfileID != profile.ID {
|
||||||
|
return nil, nil, ErrPackageNotOwned
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile, pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) loadOwnedPrompt(ctx context.Context, actor auth.Actor, promptID int64) (*repository.KolProfile, *repository.KolPrompt, *repository.KolPackage, error) {
|
||||||
|
profile, err := s.profileSvc.RequireActiveKol(ctx, actor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if prompt == nil {
|
||||||
|
return nil, nil, nil, ErrPromptNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg, err := s.pkgRepo.GetByID(ctx, actor.TenantID, prompt.PackageID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, response.ErrInternal(50062, "kol_package_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if pkg == nil {
|
||||||
|
return nil, nil, nil, errKolPackageNotFound
|
||||||
|
}
|
||||||
|
if pkg.KolProfileID != profile.ID {
|
||||||
|
return nil, nil, nil, ErrPromptNotOwned
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile, prompt, pkg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *KolPromptService) buildPromptDetail(ctx context.Context, prompt *repository.KolPrompt, includeContent bool) (*KolPromptDetailResponse, error) {
|
||||||
|
detail := &KolPromptDetailResponse{
|
||||||
|
ID: prompt.ID,
|
||||||
|
PackageID: prompt.PackageID,
|
||||||
|
Name: prompt.Name,
|
||||||
|
PlatformHint: prompt.PlatformHint,
|
||||||
|
Status: prompt.Status,
|
||||||
|
SortOrder: prompt.SortOrder,
|
||||||
|
PublishedRevisionNo: prompt.PublishedRevisionNo,
|
||||||
|
DraftRevisionNo: prompt.DraftRevisionNo,
|
||||||
|
CreatedAt: prompt.CreatedAt,
|
||||||
|
UpdatedAt: prompt.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
revisionNo := prompt.DraftRevisionNo
|
||||||
|
if revisionNo == nil {
|
||||||
|
revisionNo = prompt.PublishedRevisionNo
|
||||||
|
}
|
||||||
|
if revisionNo == nil {
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
revision, err := s.promptRepo.GetRevision(ctx, prompt.TenantID, prompt.ID, *revisionNo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50083, "kol_prompt_revision_query_failed", err.Error())
|
||||||
|
}
|
||||||
|
if revision == nil {
|
||||||
|
return nil, response.ErrInternal(50084, "kol_prompt_revision_not_found", "prompt revision not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
schema, err := decodeKolSchema(revision.SchemaJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50085, "kol_prompt_schema_decode_failed", err.Error())
|
||||||
|
}
|
||||||
|
cardConfig, err := decodeKolCardConfig(revision.CardConfigJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50086, "kol_prompt_card_config_decode_failed", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
detail.LatestSchemaJSON = schema
|
||||||
|
detail.LatestCardConfigJSON = cardConfig
|
||||||
|
|
||||||
|
if includeContent {
|
||||||
|
content, err := s.asset.Get(ctx, revision.PromptAssetKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, response.ErrInternal(50087, "kol_prompt_asset_load_failed", err.Error())
|
||||||
|
}
|
||||||
|
detail.LatestPromptContent = &content
|
||||||
|
}
|
||||||
|
|
||||||
|
return detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} {
|
||||||
|
if cardConfig == nil {
|
||||||
|
return map[string]interface{}{}
|
||||||
|
}
|
||||||
|
return cardConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKolSchema(raw []byte) (*KolSchemaJSON, error) {
|
||||||
|
schema := normalizeKolSchema(KolSchemaJSON{})
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return &schema, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(trimmed, &schema); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
schema = normalizeKolSchema(schema)
|
||||||
|
return &schema, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeKolCardConfig(raw []byte) (map[string]interface{}, error) {
|
||||||
|
trimmed := bytes.TrimSpace(raw)
|
||||||
|
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||||
|
return map[string]interface{}{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cardConfig map[string]interface{}
|
||||||
|
if err := json.Unmarshal(trimmed, &cardConfig); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return normalizeKolCardConfig(cardConfig), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KolVariableType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
KolVariableTypeInput KolVariableType = "input"
|
||||||
|
KolVariableTypeTextarea KolVariableType = "textarea"
|
||||||
|
KolVariableTypeSelect KolVariableType = "select"
|
||||||
|
KolVariableTypeNumber KolVariableType = "number"
|
||||||
|
KolVariableTypeCheckbox KolVariableType = "checkbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
type KolVariableDefinition struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Type KolVariableType `json:"type"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Placeholder *string `json:"placeholder,omitempty"`
|
||||||
|
Options []string `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KolSchemaJSON struct {
|
||||||
|
Variables []KolVariableDefinition `json:"variables"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var kolPlaceholderRE = regexp.MustCompile(`\{\{\s*([^}\s]+)\s*\}\}`)
|
||||||
|
|
||||||
|
func RenderPrompt(content string, schema KolSchemaJSON, values map[string]any) (string, error) {
|
||||||
|
schema = normalizeKolSchema(schema)
|
||||||
|
|
||||||
|
byID := make(map[string]KolVariableDefinition, len(schema.Variables))
|
||||||
|
for _, variable := range schema.Variables {
|
||||||
|
byID[variable.ID] = variable
|
||||||
|
}
|
||||||
|
|
||||||
|
missingSet := make(map[string]struct{})
|
||||||
|
missing := make([]string, 0)
|
||||||
|
addMissing := func(id string) {
|
||||||
|
if id == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := missingSet[id]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
missingSet[id] = struct{}{}
|
||||||
|
missing = append(missing, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered := kolPlaceholderRE.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
submatches := kolPlaceholderRE.FindStringSubmatch(match)
|
||||||
|
if len(submatches) < 2 {
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
id := submatches[1]
|
||||||
|
variable, exists := byID[id]
|
||||||
|
if !exists {
|
||||||
|
addMissing(id)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
value, hasValue := values[id]
|
||||||
|
if hasValue {
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
if variable.Required {
|
||||||
|
addMissing(id)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return "", fmt.Errorf("missing variables: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return rendered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashPrompt(rendered string) string {
|
||||||
|
sum := sha256.Sum256([]byte(rendered))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateSchema(schema KolSchemaJSON) error {
|
||||||
|
schema = normalizeKolSchema(schema)
|
||||||
|
|
||||||
|
seen := make(map[string]struct{}, len(schema.Variables))
|
||||||
|
for _, variable := range schema.Variables {
|
||||||
|
id := strings.TrimSpace(variable.ID)
|
||||||
|
if id == "" || !strings.HasPrefix(id, "var_") {
|
||||||
|
return fmt.Errorf("variable id must be non-empty and start with var_")
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; exists {
|
||||||
|
return fmt.Errorf("duplicate variable id: %s", id)
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
|
||||||
|
switch KolVariableType(strings.TrimSpace(string(variable.Type))) {
|
||||||
|
case KolVariableTypeInput, KolVariableTypeTextarea, KolVariableTypeSelect, KolVariableTypeNumber, KolVariableTypeCheckbox:
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported variable type: %s", variable.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func JSONEncodeSchema(schema KolSchemaJSON) ([]byte, error) {
|
||||||
|
return json.Marshal(normalizeKolSchema(schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeKolSchema(schema KolSchemaJSON) KolSchemaJSON {
|
||||||
|
if schema.Variables == nil {
|
||||||
|
schema.Variables = []KolVariableDefinition{}
|
||||||
|
}
|
||||||
|
return schema
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKolVariableRenderPrompt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
schema KolSchemaJSON
|
||||||
|
values map[string]any
|
||||||
|
want string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
content: "Hello {{var_a}} from {{var_b}}",
|
||||||
|
schema: KolSchemaJSON{
|
||||||
|
Variables: []KolVariableDefinition{
|
||||||
|
{ID: "var_a", Type: KolVariableTypeInput, Required: true},
|
||||||
|
{ID: "var_b", Type: KolVariableTypeSelect},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
values: map[string]any{
|
||||||
|
"var_a": "宜家",
|
||||||
|
"var_b": "家具",
|
||||||
|
},
|
||||||
|
want: "Hello 宜家 from 家具",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing required",
|
||||||
|
content: "Hello {{var_required}}",
|
||||||
|
schema: KolSchemaJSON{
|
||||||
|
Variables: []KolVariableDefinition{
|
||||||
|
{ID: "var_required", Type: KolVariableTypeInput, Required: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
values: map[string]any{},
|
||||||
|
wantErr: "missing variables",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rendered, err := RenderPrompt(tc.content, tc.schema, tc.values)
|
||||||
|
if tc.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), tc.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.want, rendered)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKolVariableHashPromptStable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Equal(t, HashPrompt("abc"), HashPrompt("abc"))
|
||||||
|
require.NotEqual(t, HashPrompt("abc"), HashPrompt("abd"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKolVariableValidateSchemaRejectsDuplicateID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := ValidateSchema(KolSchemaJSON{
|
||||||
|
Variables: []KolVariableDefinition{
|
||||||
|
{ID: "var_a", Type: KolVariableTypeInput},
|
||||||
|
{ID: "var_a", Type: KolVariableTypeSelect},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "duplicate variable id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKolVariableValidateSchemaRejectsInvalidID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := ValidateSchema(KolSchemaJSON{
|
||||||
|
Variables: []KolVariableDefinition{
|
||||||
|
{ID: "bad_id", Type: KolVariableTypeInput},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "start with var_")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user