Files
geo/server/internal/tenant/app/kol_prompt_service.go
T
root b2605abd6a feat: Enhance Kol Generation Service with web search and knowledge group support
- Added `EnableWebSearch` and `KnowledgeGroupIDs` fields to `KolGenerationSubmitRequest`.
- Updated `Submit` method in `KolGenerationService` to handle new request fields.
- Integrated web search and knowledge group validation based on card configuration.
- Introduced caching mechanisms in `KolPackageService`, `KolPromptService`, and `KolSubscriptionAdminService` to improve performance.
- Implemented knowledge context resolution in `KolGenerationWorker` to enrich prompts with relevant knowledge snippets.
- Added utility functions for handling card configuration in both backend and frontend.
- Created tests for knowledge extraction and rendering to ensure accuracy and reliability.
2026-04-18 13:47:32 +08:00

594 lines
20 KiB
Go

package app
import (
"bytes"
"context"
"encoding/json"
"strings"
"time"
"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/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")
errKolPromptContentEmpty = response.ErrConflict(40961, "kol_prompt_content_missing", "prompt content is required before publish or activate")
)
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 PublishPromptInput struct {
Name string `json:"name" binding:"required"`
PlatformHint *string `json:"platform_hint"`
SortOrder *int `json:"sort_order"`
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 KolPromptService struct {
pool *pgxpool.Pool
profileSvc *KolProfileService
pkgRepo repository.KolPackageRepository
promptRepo repository.KolPromptRepository
subRepo repository.KolSubscriptionRepository
asset *KolPromptAsset
cache sharedcache.Cache
}
func NewKolPromptService(
pool *pgxpool.Pool,
profileSvc *KolProfileService,
pkgRepo repository.KolPackageRepository,
promptRepo repository.KolPromptRepository,
subRepo repository.KolSubscriptionRepository,
asset *KolPromptAsset,
) *KolPromptService {
return &KolPromptService{
pool: pool,
profileSvc: profileSvc,
pkgRepo: pkgRepo,
promptRepo: promptRepo,
subRepo: subRepo,
asset: asset,
}
}
func (s *KolPromptService) WithCache(c sharedcache.Cache) *KolPromptService {
s.cache = c
return s
}
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())
}
InvalidateKolPromptCaches(ctx, s.cache)
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
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, false)
}
func (s *KolPromptService) Save(ctx context.Context, actor auth.Actor, promptID int64, input PublishPromptInput) (*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")
}
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())
}
sortOrder := prompt.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
const currentRevisionNo = 1
assetKey, err := s.asset.Put(ctx, actor.TenantID, promptID, currentRevisionNo, 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")
}
publishedRevisionNo := prompt.PublishedRevisionNo
if prompt.Status == "active" {
revNo := currentRevisionNo
publishedRevisionNo = &revNo
}
if err := s.promptRepo.Save(ctx, repository.SaveKolPromptInput{
ID: promptID,
TenantID: actor.TenantID,
Name: name,
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
SortOrder: sortOrder,
PromptAssetKey: assetKey,
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
Status: prompt.Status,
PublishedRevisionNo: publishedRevisionNo,
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
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, true)
}
func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64, input PublishPromptInput) (*KolPromptDetailResponse, error) {
_, prompt, pkg, 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")
}
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())
}
sortOrder := prompt.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
const publishedRevisionNo = 1
assetKey, err := s.asset.Put(ctx, actor.TenantID, promptID, publishedRevisionNo, 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(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction")
}
defer tx.Rollback(ctx)
promptTx := repository.NewKolPromptRepository(tx)
subTx := repository.NewKolSubscriptionRepository(tx)
if err := promptTx.Publish(ctx, repository.PublishKolPromptInput{
ID: promptID,
TenantID: actor.TenantID,
Name: name,
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
SortOrder: sortOrder,
PromptAssetKey: assetKey,
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
PublishedRevisionNo: publishedRevisionNo,
UpdatedBy: actor.UserID,
}); err != nil {
return nil, response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error())
}
if err := s.grantPromptToActiveSubscribers(ctx, subTx, pkg.ID, promptID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50081, "kol_prompt_publish_commit_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
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, true)
}
func (s *KolPromptService) Activate(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) {
_, prompt, pkg, err := s.loadOwnedPrompt(ctx, actor, promptID)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50088, "kol_prompt_activate_tx_failed", "failed to begin prompt activate transaction")
}
defer tx.Rollback(ctx)
promptTx := repository.NewKolPromptRepository(tx)
subTx := repository.NewKolSubscriptionRepository(tx)
switch {
case prompt.PromptAssetKey != nil && strings.TrimSpace(*prompt.PromptAssetKey) != "":
if err := promptTx.SetPublishedRevision(ctx, actor.TenantID, promptID, 1, actor.UserID); err != nil {
return nil, response.ErrInternal(50090, "kol_prompt_activate_failed", err.Error())
}
default:
return nil, errKolPromptContentEmpty
}
if err := s.grantPromptToActiveSubscribers(ctx, subTx, pkg.ID, promptID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50091, "kol_prompt_activate_commit_failed", "failed to commit prompt activate transaction")
}
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
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, false)
}
func (s *KolPromptService) Archive(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) {
if _, _, _, err := s.loadOwnedPrompt(ctx, actor, promptID); err != nil {
return nil, err
}
if err := s.promptRepo.UpdateStatus(ctx, actor.TenantID, promptID, "archived", actor.UserID); err != nil {
return nil, response.ErrInternal(50092, "kol_prompt_archive_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
}
InvalidateKolPromptCaches(ctx, s.cache)
return s.buildPromptDetail(ctx, updated, false)
}
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())
}
InvalidateKolPromptCaches(ctx, s.cache)
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,
}
if len(prompt.SchemaJSON) == 0 && len(prompt.CardConfigJSON) == 0 && (prompt.PromptAssetKey == nil || strings.TrimSpace(*prompt.PromptAssetKey) == "") {
return detail, nil
}
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return nil, response.ErrInternal(50085, "kol_prompt_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(prompt.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 && prompt.PromptAssetKey != nil && strings.TrimSpace(*prompt.PromptAssetKey) != "" {
content, err := s.asset.Get(ctx, *prompt.PromptAssetKey)
if err != nil {
return nil, response.ErrInternal(50087, "kol_prompt_asset_load_failed", err.Error())
}
detail.LatestPromptContent = &content
}
return detail, nil
}
func (s *KolPromptService) grantPromptToActiveSubscribers(
ctx context.Context,
subRepo repository.KolSubscriptionRepository,
packageID, promptID int64,
) error {
subscribers, err := subRepo.ListActiveSubscribersForPackage(ctx, packageID)
if err != nil {
return response.ErrInternal(50083, "kol_subscription_query_failed", err.Error())
}
for _, sub := range subscribers {
if err := subRepo.CreateAccessRow(ctx, repository.CreateAccessRowInput{
TenantID: sub.TenantID,
SubscriptionID: sub.ID,
PackageID: packageID,
PromptID: promptID,
}); err != nil {
return response.ErrInternal(50084, "kol_subscription_access_create_failed", err.Error())
}
}
return nil
}
func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} {
normalized := map[string]interface{}{
"allow_web_search": false,
"allow_user_knowledge": true,
}
for key, value := range cardConfig {
normalized[key] = value
}
normalized["allow_web_search"] = kolCardConfigBoolValue(cardConfig["allow_web_search"], false)
normalized["allow_user_knowledge"] = kolCardConfigBoolValue(cardConfig["allow_user_knowledge"], true)
return normalized
}
func kolCardConfigBoolValue(value interface{}, defaultValue bool) bool {
switch typed := value.(type) {
case bool:
return typed
case *bool:
if typed != nil {
return *typed
}
}
return defaultValue
}
func kolCardConfigAllowsWebSearch(cardConfig map[string]interface{}) bool {
return kolCardConfigBoolValue(cardConfig["allow_web_search"], false)
}
func kolCardConfigAllowsUserKnowledge(cardConfig map[string]interface{}) bool {
return kolCardConfigBoolValue(cardConfig["allow_user_knowledge"], true)
}
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 normalizeKolCardConfig(nil), nil
}
var cardConfig map[string]interface{}
if err := json.Unmarshal(trimmed, &cardConfig); err != nil {
return nil, err
}
return normalizeKolCardConfig(cardConfig), nil
}