687 lines
22 KiB
Go
687 lines
22 KiB
Go
|
|
package app
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"context"
|
|||
|
|
"encoding/json"
|
|||
|
|
"fmt"
|
|||
|
|
"net/url"
|
|||
|
|
"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/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/shared/stream"
|
|||
|
|
"github.com/geo-platform/tenant-api/internal/tenant/prompts"
|
|||
|
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const (
|
|||
|
|
articleImitationTaskType = "imitation"
|
|||
|
|
articleImitationSourceType = "imitation"
|
|||
|
|
articleImitationFallbackTitle = "仿写文章生成中"
|
|||
|
|
maxImitationSourceContentRunes = 24000
|
|||
|
|
defaultImitationWebSearchLimit = int32(5)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
type ArticleImitationService struct {
|
|||
|
|
pool *pgxpool.Pool
|
|||
|
|
llm llm.Client
|
|||
|
|
rabbitMQ *rabbitmq.Client
|
|||
|
|
knowledge *KnowledgeService
|
|||
|
|
streams *stream.GenerationHub
|
|||
|
|
streamEnabled bool
|
|||
|
|
articleTimeout time.Duration
|
|||
|
|
maxOutputTokens int64
|
|||
|
|
cache sharedcache.Cache
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type GenerateImitationRequest struct {
|
|||
|
|
SourceURL string `json:"source_url" binding:"required"`
|
|||
|
|
SourceTitle string `json:"source_title"`
|
|||
|
|
Locale string `json:"locale"`
|
|||
|
|
Industry string `json:"industry"`
|
|||
|
|
BrandName string `json:"brand_name"`
|
|||
|
|
Region string `json:"region"`
|
|||
|
|
TargetAudience string `json:"target_audience"`
|
|||
|
|
ContentGoal string `json:"content_goal"`
|
|||
|
|
Tone string `json:"tone"`
|
|||
|
|
LengthGoal string `json:"length_goal"`
|
|||
|
|
Keywords []string `json:"keywords"`
|
|||
|
|
PreservePoints string `json:"preserve_points"`
|
|||
|
|
AvoidPoints string `json:"avoid_points"`
|
|||
|
|
ExtraRequirements string `json:"extra_requirements"`
|
|||
|
|
EnableWebSearch bool `json:"enable_web_search"`
|
|||
|
|
WebSearchLimit *int32 `json:"web_search_limit"`
|
|||
|
|
KnowledgeGroupIDs []int64 `json:"knowledge_group_ids"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type GenerateImitationResponse struct {
|
|||
|
|
ArticleID int64 `json:"article_id"`
|
|||
|
|
TaskID int64 `json:"task_id"`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
type articleImitationJob struct {
|
|||
|
|
OperatorID int64
|
|||
|
|
TenantID int64
|
|||
|
|
ArticleID int64
|
|||
|
|
TaskID int64
|
|||
|
|
ReservationID int64
|
|||
|
|
InputParams map[string]interface{}
|
|||
|
|
InitialTitle string
|
|||
|
|
WebSearch llm.WebSearchOptions
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func NewArticleImitationService(
|
|||
|
|
pool *pgxpool.Pool,
|
|||
|
|
llmClient llm.Client,
|
|||
|
|
rabbitMQClient *rabbitmq.Client,
|
|||
|
|
knowledge *KnowledgeService,
|
|||
|
|
streams *stream.GenerationHub,
|
|||
|
|
cfg config.GenerationConfig,
|
|||
|
|
llmMaxOutputTokens int64,
|
|||
|
|
) *ArticleImitationService {
|
|||
|
|
articleTimeout := cfg.ArticleTimeout
|
|||
|
|
if articleTimeout <= 0 {
|
|||
|
|
articleTimeout = 8 * time.Minute
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &ArticleImitationService{
|
|||
|
|
pool: pool,
|
|||
|
|
llm: llmClient,
|
|||
|
|
rabbitMQ: rabbitMQClient,
|
|||
|
|
knowledge: knowledge,
|
|||
|
|
streams: streams,
|
|||
|
|
streamEnabled: cfg.StreamEnabled,
|
|||
|
|
articleTimeout: articleTimeout,
|
|||
|
|
maxOutputTokens: llmMaxOutputTokens,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *ArticleImitationService) WithCache(c sharedcache.Cache) *ArticleImitationService {
|
|||
|
|
s.cache = c
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImitationRequest) (*GenerateImitationResponse, error) {
|
|||
|
|
actor := auth.MustActor(ctx)
|
|||
|
|
if err := s.llm.Validate(); err != nil {
|
|||
|
|
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sourceURL, err := normalizeImitationSourceURL(req.SourceURL)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, err
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
quotaRepo := repository.NewQuotaRepository(s.pool)
|
|||
|
|
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
|||
|
|
if err != nil || balance < 1 {
|
|||
|
|
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
inputParams := buildImitationInputParams(sourceURL, req)
|
|||
|
|
inputJSON, _ := json.Marshal(inputParams)
|
|||
|
|
initialTitle := buildImitationInitialTitle(req.SourceTitle)
|
|||
|
|
wizardStateJSON, _ := json.Marshal(map[string]interface{}{
|
|||
|
|
"title": initialTitle,
|
|||
|
|
"source_url": sourceURL,
|
|||
|
|
"source_title": strings.TrimSpace(req.SourceTitle),
|
|||
|
|
"current_step": 0,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
tx, err := s.pool.Begin(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to begin generation transaction")
|
|||
|
|
}
|
|||
|
|
defer func() {
|
|||
|
|
_ = tx.Rollback(ctx)
|
|||
|
|
}()
|
|||
|
|
|
|||
|
|
quotaTx := repository.NewQuotaRepository(tx)
|
|||
|
|
auditTx := repository.NewAuditRepository(tx)
|
|||
|
|
|
|||
|
|
newBalance := balance - 1
|
|||
|
|
reason := "generation_reserve"
|
|||
|
|
referenceType := "generation_task"
|
|||
|
|
_, err = quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
|||
|
|
TenantID: actor.TenantID,
|
|||
|
|
OperatorID: actor.UserID,
|
|||
|
|
QuotaType: "article_generation",
|
|||
|
|
Delta: -1,
|
|||
|
|
BalanceAfter: newBalance,
|
|||
|
|
Reason: &reason,
|
|||
|
|
ReferenceType: &referenceType,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to reserve generation quota")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
|||
|
|
TenantID: actor.TenantID,
|
|||
|
|
OperatorID: actor.UserID,
|
|||
|
|
QuotaType: "article_generation",
|
|||
|
|
ResourceType: "article",
|
|||
|
|
ResourceID: nil,
|
|||
|
|
ReservedAmount: 1,
|
|||
|
|
ExpireAt: time.Now().Add(time.Hour),
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to create quota reservation")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var articleID int64
|
|||
|
|
err = tx.QueryRow(ctx, `
|
|||
|
|
INSERT INTO articles (tenant_id, source_type, generate_status, publish_status, wizard_state_json)
|
|||
|
|
VALUES ($1, $2, 'generating', 'unpublished', $3)
|
|||
|
|
RETURNING id
|
|||
|
|
`, actor.TenantID, articleImitationSourceType, wizardStateJSON).Scan(&articleID)
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to create imitation article")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to update quota reservation resource")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
|||
|
|
OperatorID: actor.UserID,
|
|||
|
|
TenantID: actor.TenantID,
|
|||
|
|
ArticleID: &articleID,
|
|||
|
|
QuotaReservationID: &reservationID,
|
|||
|
|
TaskType: articleImitationTaskType,
|
|||
|
|
InputParamsJSON: inputJSON,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to create generation task")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := tx.Commit(ctx); err != nil {
|
|||
|
|
return nil, response.ErrInternal(50012, "create_failed", "failed to commit generation task")
|
|||
|
|
}
|
|||
|
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
|||
|
|
|
|||
|
|
job := articleImitationJob{
|
|||
|
|
OperatorID: actor.UserID,
|
|||
|
|
TenantID: actor.TenantID,
|
|||
|
|
ArticleID: articleID,
|
|||
|
|
TaskID: taskID,
|
|||
|
|
ReservationID: reservationID,
|
|||
|
|
InputParams: inputParams,
|
|||
|
|
InitialTitle: initialTitle,
|
|||
|
|
WebSearch: llm.WebSearchOptions{
|
|||
|
|
Enabled: req.EnableWebSearch,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 {
|
|||
|
|
job.WebSearch.Limit = *req.WebSearchLimit
|
|||
|
|
} else if req.EnableWebSearch {
|
|||
|
|
job.WebSearch.Limit = defaultImitationWebSearchLimit
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{
|
|||
|
|
TaskID: taskID,
|
|||
|
|
TaskType: articleImitationTaskType,
|
|||
|
|
TenantID: actor.TenantID,
|
|||
|
|
ArticleID: articleID,
|
|||
|
|
}); err != nil {
|
|||
|
|
s.failGeneration(context.Background(), job, "queue_enqueue", err)
|
|||
|
|
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if s.streamEnabled && s.streams != nil {
|
|||
|
|
s.streams.Start(articleID, taskID, initialTitle)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return &GenerateImitationResponse{ArticleID: articleID, TaskID: taskID}, nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *ArticleImitationService) ProcessQueuedTask(ctx context.Context, task ClaimedGenerationTask) {
|
|||
|
|
inputParams := cloneInputParams(task.InputParams)
|
|||
|
|
job := articleImitationJob{
|
|||
|
|
OperatorID: task.OperatorID,
|
|||
|
|
TenantID: task.TenantID,
|
|||
|
|
ArticleID: task.ArticleID,
|
|||
|
|
TaskID: task.ID,
|
|||
|
|
ReservationID: task.QuotaReservationID,
|
|||
|
|
InputParams: inputParams,
|
|||
|
|
InitialTitle: buildImitationInitialTitle(extractString(inputParams, "source_title")),
|
|||
|
|
WebSearch: llm.WebSearchOptions{
|
|||
|
|
Enabled: extractBool(inputParams, "enable_web_search"),
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
if limit := extractInt(inputParams, "web_search_limit"); limit > 0 {
|
|||
|
|
job.WebSearch.Limit = int32(limit)
|
|||
|
|
} else if job.WebSearch.Enabled {
|
|||
|
|
job.WebSearch.Limit = defaultImitationWebSearchLimit
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
s.executeGeneration(ctx, job)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *ArticleImitationService) HandleTaskFailure(ctx context.Context, task ClaimedGenerationTask, stage string, taskErr error) {
|
|||
|
|
job := articleImitationJob{
|
|||
|
|
OperatorID: task.OperatorID,
|
|||
|
|
TenantID: task.TenantID,
|
|||
|
|
ArticleID: task.ArticleID,
|
|||
|
|
TaskID: task.ID,
|
|||
|
|
ReservationID: task.QuotaReservationID,
|
|||
|
|
InputParams: cloneInputParams(task.InputParams),
|
|||
|
|
InitialTitle: buildImitationInitialTitle(extractString(task.InputParams, "source_title")),
|
|||
|
|
}
|
|||
|
|
s.failGeneration(ctx, job, stage, taskErr)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *ArticleImitationService) executeGeneration(ctx context.Context, job articleImitationJob) {
|
|||
|
|
now := time.Now()
|
|||
|
|
title := strings.TrimSpace(job.InitialTitle)
|
|||
|
|
if title == "" {
|
|||
|
|
title = articleImitationFallbackTitle
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
auditRepo := repository.NewAuditRepository(s.pool)
|
|||
|
|
_ = auditRepo.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
|||
|
|
ID: job.TaskID,
|
|||
|
|
TenantID: job.TenantID,
|
|||
|
|
Status: "running",
|
|||
|
|
StartedAt: &now,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
sourceURL := strings.TrimSpace(extractString(job.InputParams, "source_url"))
|
|||
|
|
if sourceURL == "" {
|
|||
|
|
s.failGeneration(ctx, job, "task_load", fmt.Errorf("source_url is required"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if s.knowledge == nil {
|
|||
|
|
s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("knowledge service is not configured"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parsed, err := s.knowledge.parseWebsiteKnowledge(ctx, sourceURL)
|
|||
|
|
if err != nil {
|
|||
|
|
s.failGeneration(ctx, job, "source_fetch", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sourceContent := truncateRunes(strings.TrimSpace(parsed.Markdown), maxImitationSourceContentRunes)
|
|||
|
|
if sourceContent == "" {
|
|||
|
|
sourceContent = truncateRunes(strings.TrimSpace(parsed.Text), maxImitationSourceContentRunes)
|
|||
|
|
}
|
|||
|
|
if sourceContent == "" {
|
|||
|
|
s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("source article content is empty"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
knowledgePrompt := ""
|
|||
|
|
if knowledgeGroupIDs := extractKnowledgeGroupIDs(job.InputParams["knowledge_group_ids"]); len(knowledgeGroupIDs) > 0 {
|
|||
|
|
if s.knowledge == nil {
|
|||
|
|
s.failGeneration(ctx, job, "knowledge_resolve", fmt.Errorf("knowledge service is not configured"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
knowledgeContext, resolveErr := s.knowledge.ResolveContext(
|
|||
|
|
ctx,
|
|||
|
|
job.TenantID,
|
|||
|
|
knowledgeGroupIDs,
|
|||
|
|
buildImitationKnowledgeQuery(job.InputParams),
|
|||
|
|
)
|
|||
|
|
if resolveErr != nil {
|
|||
|
|
s.failGeneration(ctx, job, "knowledge_resolve", resolveErr)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
knowledgePrompt = s.knowledge.RenderPromptSection(knowledgeContext)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
prompt := buildImitationGenerationPrompt(job.InputParams, sourceContent, knowledgePrompt)
|
|||
|
|
req := llm.GenerateRequest{
|
|||
|
|
Prompt: prompt,
|
|||
|
|
Timeout: s.articleTimeout,
|
|||
|
|
MaxOutputTokens: s.maxOutputTokens,
|
|||
|
|
}
|
|||
|
|
if job.WebSearch.Enabled {
|
|||
|
|
req.WebSearch = &job.WebSearch
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
result, err := s.llm.Generate(ctx, req, func(delta string) {
|
|||
|
|
if s.streamEnabled && s.streams != nil && delta != "" {
|
|||
|
|
s.streams.AppendDelta(job.ArticleID, job.TaskID, title, delta)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
s.failGeneration(ctx, job, "llm_generate", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
content := strings.TrimSpace(result.Content)
|
|||
|
|
if content == "" {
|
|||
|
|
s.failGeneration(ctx, job, "llm_generate", fmt.Errorf("模型返回空内容"))
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
title = resolveGeneratedImitationTitle(content, job.InputParams)
|
|||
|
|
wordCount := estimateWordCount(content)
|
|||
|
|
|
|||
|
|
tx, err := s.pool.Begin(ctx)
|
|||
|
|
if err != nil {
|
|||
|
|
s.failGeneration(ctx, job, "persist_begin_tx", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
defer rollbackGenerationTx(tx)
|
|||
|
|
|
|||
|
|
failWithRollback := func(stage string, err error) {
|
|||
|
|
rollbackGenerationTx(tx)
|
|||
|
|
s.failGeneration(ctx, job, stage, err)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
articleRepo := repository.NewArticleRepository(tx)
|
|||
|
|
quotaRepo := repository.NewQuotaRepository(tx)
|
|||
|
|
auditTx := repository.NewAuditRepository(tx)
|
|||
|
|
|
|||
|
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
|||
|
|
ArticleID: job.ArticleID,
|
|||
|
|
VersionNo: 1,
|
|||
|
|
Title: title,
|
|||
|
|
HTMLContent: "",
|
|||
|
|
MarkdownContent: content,
|
|||
|
|
WordCount: wordCount,
|
|||
|
|
SourceLabel: result.Model,
|
|||
|
|
})
|
|||
|
|
if err != nil {
|
|||
|
|
failWithRollback("persist_create_version", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := articleRepo.UpdateArticleCurrentVersion(ctx, job.ArticleID, job.TenantID, versionID); err != nil {
|
|||
|
|
failWithRollback("persist_current_version", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := articleRepo.UpdateArticleGenerateStatus(ctx, job.ArticleID, job.TenantID, "completed"); err != nil {
|
|||
|
|
failWithRollback("persist_article_status", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
completed := time.Now()
|
|||
|
|
if err := auditTx.UpdateGenerationTaskStatus(ctx, repository.GenerationTaskStatusInput{
|
|||
|
|
ID: job.TaskID,
|
|||
|
|
TenantID: job.TenantID,
|
|||
|
|
Status: "completed",
|
|||
|
|
StartedAt: &now,
|
|||
|
|
CompletedAt: &completed,
|
|||
|
|
}); err != nil {
|
|||
|
|
failWithRollback("persist_task_status", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if err := quotaRepo.ConfirmReservation(ctx, job.ReservationID, job.TenantID); err != nil {
|
|||
|
|
failWithRollback("confirm_reservation", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if err := tx.Commit(ctx); err != nil {
|
|||
|
|
failWithRollback("persist_commit", err)
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cleanupCtx, cancel := newGenerationCleanupContext()
|
|||
|
|
defer cancel()
|
|||
|
|
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
|||
|
|
|
|||
|
|
if s.streamEnabled && s.streams != nil {
|
|||
|
|
s.streams.Complete(job.ArticleID, job.TaskID, title, content)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func (s *ArticleImitationService) failGeneration(ctx context.Context, job articleImitationJob, stage string, genErr error) {
|
|||
|
|
cleanupCtx, cancel := newGenerationCleanupContext()
|
|||
|
|
defer cancel()
|
|||
|
|
|
|||
|
|
errMsg := formatGenerationFailure(stage, genErr)
|
|||
|
|
now := time.Now()
|
|||
|
|
title := strings.TrimSpace(job.InitialTitle)
|
|||
|
|
if title == "" {
|
|||
|
|
title = articleImitationFallbackTitle
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
auditRepo := repository.NewAuditRepository(s.pool)
|
|||
|
|
articleRepo := repository.NewArticleRepository(s.pool)
|
|||
|
|
quotaRepo := repository.NewQuotaRepository(s.pool)
|
|||
|
|
|
|||
|
|
_ = auditRepo.UpdateGenerationTaskStatus(cleanupCtx, repository.GenerationTaskStatusInput{
|
|||
|
|
ID: job.TaskID,
|
|||
|
|
TenantID: job.TenantID,
|
|||
|
|
Status: "failed",
|
|||
|
|
ErrorMessage: &errMsg,
|
|||
|
|
CompletedAt: &now,
|
|||
|
|
})
|
|||
|
|
if job.ArticleID > 0 {
|
|||
|
|
_ = articleRepo.UpdateArticleGenerateStatus(cleanupCtx, job.ArticleID, job.TenantID, "failed")
|
|||
|
|
_, _ = s.pool.Exec(cleanupCtx, `
|
|||
|
|
UPDATE articles
|
|||
|
|
SET wizard_state_json = jsonb_set(COALESCE(wizard_state_json, '{}'::jsonb), '{title}', to_jsonb($1::text), true),
|
|||
|
|
updated_at = NOW()
|
|||
|
|
WHERE id = $2 AND tenant_id = $3
|
|||
|
|
`, title, job.ArticleID, job.TenantID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if job.ReservationID > 0 {
|
|||
|
|
balance, balanceErr := quotaRepo.GetCurrentBalance(cleanupCtx, job.TenantID, "article_generation")
|
|||
|
|
if balanceErr == nil {
|
|||
|
|
reason := "generation_refund"
|
|||
|
|
referenceType := "generation_task"
|
|||
|
|
taskReferenceID := job.TaskID
|
|||
|
|
_, _ = quotaRepo.InsertQuotaLedger(cleanupCtx, repository.QuotaLedgerInput{
|
|||
|
|
TenantID: job.TenantID,
|
|||
|
|
OperatorID: job.OperatorID,
|
|||
|
|
QuotaType: "article_generation",
|
|||
|
|
Delta: 1,
|
|||
|
|
BalanceAfter: balance + 1,
|
|||
|
|
Reason: &reason,
|
|||
|
|
ReferenceType: &referenceType,
|
|||
|
|
ReferenceID: &taskReferenceID,
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
_ = quotaRepo.RefundReservation(cleanupCtx, job.ReservationID, job.TenantID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if job.ArticleID > 0 {
|
|||
|
|
invalidateArticleCaches(cleanupCtx, s.cache, job.TenantID, &job.ArticleID)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if s.streamEnabled && s.streams != nil && job.ArticleID > 0 {
|
|||
|
|
s.streams.Fail(job.ArticleID, job.TaskID, title, errMsg)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func normalizeImitationSourceURL(raw string) (string, error) {
|
|||
|
|
trimmed := strings.TrimSpace(raw)
|
|||
|
|
if trimmed == "" {
|
|||
|
|
return "", response.ErrBadRequest(40001, "source_url_required", "source_url is required")
|
|||
|
|
}
|
|||
|
|
parsed, err := url.Parse(trimmed)
|
|||
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|||
|
|
return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must be a valid URL")
|
|||
|
|
}
|
|||
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|||
|
|
return "", response.ErrBadRequest(40001, "invalid_source_url", "source_url must use http or https")
|
|||
|
|
}
|
|||
|
|
return parsed.String(), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildImitationInputParams(sourceURL string, req GenerateImitationRequest) map[string]interface{} {
|
|||
|
|
params := map[string]interface{}{
|
|||
|
|
"source_url": sourceURL,
|
|||
|
|
"source_title": strings.TrimSpace(req.SourceTitle),
|
|||
|
|
"locale": normalizeImitationLocale(req.Locale),
|
|||
|
|
"industry": strings.TrimSpace(req.Industry),
|
|||
|
|
"brand_name": strings.TrimSpace(req.BrandName),
|
|||
|
|
"region": strings.TrimSpace(req.Region),
|
|||
|
|
"target_audience": strings.TrimSpace(req.TargetAudience),
|
|||
|
|
"content_goal": strings.TrimSpace(req.ContentGoal),
|
|||
|
|
"tone": strings.TrimSpace(req.Tone),
|
|||
|
|
"length_goal": strings.TrimSpace(req.LengthGoal),
|
|||
|
|
"keywords": normalizeImitationKeywords(req.Keywords),
|
|||
|
|
"preserve_points": strings.TrimSpace(req.PreservePoints),
|
|||
|
|
"avoid_points": strings.TrimSpace(req.AvoidPoints),
|
|||
|
|
"extra_requirements": strings.TrimSpace(req.ExtraRequirements),
|
|||
|
|
"enable_web_search": req.EnableWebSearch,
|
|||
|
|
"knowledge_group_ids": normalizeInt64IDs(req.KnowledgeGroupIDs),
|
|||
|
|
}
|
|||
|
|
if req.WebSearchLimit != nil && *req.WebSearchLimit > 0 {
|
|||
|
|
params["web_search_limit"] = *req.WebSearchLimit
|
|||
|
|
}
|
|||
|
|
return params
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildImitationInitialTitle(sourceTitle string) string {
|
|||
|
|
title := strings.TrimSpace(sourceTitle)
|
|||
|
|
if title == "" {
|
|||
|
|
return articleImitationFallbackTitle
|
|||
|
|
}
|
|||
|
|
return "仿写:" + title
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildImitationGenerationPrompt(params map[string]interface{}, sourceContent string, knowledgePrompt string) string {
|
|||
|
|
locale := normalizeImitationLocale(extractString(params, "locale"))
|
|||
|
|
sourceTitle := strings.TrimSpace(extractString(params, "source_title"))
|
|||
|
|
sourceURL := strings.TrimSpace(extractString(params, "source_url"))
|
|||
|
|
keywords := extractStringList(params["keywords"], 16)
|
|||
|
|
knowledgePrompt = strings.TrimSpace(knowledgePrompt)
|
|||
|
|
|
|||
|
|
var sourceMeta strings.Builder
|
|||
|
|
if sourceTitle != "" {
|
|||
|
|
appendRawPromptLine(&sourceMeta, "标题", sourceTitle)
|
|||
|
|
}
|
|||
|
|
if sourceURL != "" {
|
|||
|
|
appendRawPromptLine(&sourceMeta, "URL", sourceURL)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
var settings strings.Builder
|
|||
|
|
appendPromptLine(&settings, "industry", extractString(params, "industry"))
|
|||
|
|
appendPromptLine(&settings, "brand_name", extractString(params, "brand_name"))
|
|||
|
|
appendPromptLine(&settings, "region", extractString(params, "region"))
|
|||
|
|
appendPromptLine(&settings, "target_audience", extractString(params, "target_audience"))
|
|||
|
|
appendPromptLine(&settings, "content_goal", extractString(params, "content_goal"))
|
|||
|
|
appendPromptLine(&settings, "tone", extractString(params, "tone"))
|
|||
|
|
appendPromptLine(&settings, "length_goal", extractString(params, "length_goal"))
|
|||
|
|
if len(keywords) > 0 {
|
|||
|
|
appendPromptLine(&settings, "keywords", strings.Join(keywords, "、"))
|
|||
|
|
}
|
|||
|
|
appendPromptLine(&settings, "preserve_points", extractString(params, "preserve_points"))
|
|||
|
|
appendPromptLine(&settings, "avoid_points", extractString(params, "avoid_points"))
|
|||
|
|
appendPromptLine(&settings, "extra_requirements", extractString(params, "extra_requirements"))
|
|||
|
|
|
|||
|
|
knowledgeSection := ""
|
|||
|
|
if knowledgePrompt != "" {
|
|||
|
|
knowledgeSection = "## 知识库参考\n" + knowledgePrompt
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return prompts.ArticleImitationPrompt(
|
|||
|
|
locale,
|
|||
|
|
sourceMeta.String(),
|
|||
|
|
settings.String(),
|
|||
|
|
knowledgeSection,
|
|||
|
|
sourceContent,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func buildImitationKnowledgeQuery(params map[string]interface{}) string {
|
|||
|
|
if len(params) == 0 {
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parts := make([]string, 0, 8)
|
|||
|
|
appendValue := func(value string) {
|
|||
|
|
value = strings.TrimSpace(value)
|
|||
|
|
if value == "" {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
parts = append(parts, value)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
appendValue(extractString(params, "source_title"))
|
|||
|
|
appendValue(extractString(params, "industry"))
|
|||
|
|
appendValue(extractString(params, "brand_name"))
|
|||
|
|
appendValue(extractString(params, "region"))
|
|||
|
|
appendValue(extractString(params, "target_audience"))
|
|||
|
|
appendValue(extractString(params, "content_goal"))
|
|||
|
|
appendValue(extractString(params, "tone"))
|
|||
|
|
appendValue(strings.Join(extractStringList(params["keywords"], 16), "\n"))
|
|||
|
|
appendValue(extractString(params, "extra_requirements"))
|
|||
|
|
|
|||
|
|
return strings.Join(parts, "\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func appendPromptLine(b *strings.Builder, label, value string) {
|
|||
|
|
value = strings.TrimSpace(value)
|
|||
|
|
if value == "" {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
b.WriteString("- " + prompts.ArticleImitationSettingLabel(label) + ":" + value + "\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func appendRawPromptLine(b *strings.Builder, label, value string) {
|
|||
|
|
value = strings.TrimSpace(value)
|
|||
|
|
if value == "" {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
b.WriteString("- " + label + ":" + value + "\n")
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func resolveGeneratedImitationTitle(markdown string, params map[string]interface{}) string {
|
|||
|
|
for _, line := range strings.Split(markdown, "\n") {
|
|||
|
|
line = strings.TrimSpace(line)
|
|||
|
|
if strings.HasPrefix(line, "#") {
|
|||
|
|
title := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
|||
|
|
if title != "" {
|
|||
|
|
return title
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return buildImitationInitialTitle(extractString(params, "source_title"))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func normalizeImitationLocale(value string) string {
|
|||
|
|
if strings.EqualFold(strings.TrimSpace(value), "en-US") {
|
|||
|
|
return "en-US"
|
|||
|
|
}
|
|||
|
|
return "zh-CN"
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func normalizeImitationKeywords(values []string) []string {
|
|||
|
|
if len(values) == 0 {
|
|||
|
|
return []string{}
|
|||
|
|
}
|
|||
|
|
seen := make(map[string]struct{}, len(values))
|
|||
|
|
keywords := make([]string, 0, len(values))
|
|||
|
|
for _, item := range values {
|
|||
|
|
item = strings.TrimSpace(item)
|
|||
|
|
if item == "" {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
key := strings.ToLower(item)
|
|||
|
|
if _, exists := seen[key]; exists {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
seen[key] = struct{}{}
|
|||
|
|
keywords = append(keywords, item)
|
|||
|
|
if len(keywords) >= 16 {
|
|||
|
|
break
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return keywords
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
func truncateRunes(value string, maxRunes int) string {
|
|||
|
|
if maxRunes <= 0 {
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
runes := []rune(value)
|
|||
|
|
if len(runes) <= maxRunes {
|
|||
|
|
return value
|
|||
|
|
}
|
|||
|
|
return string(runes[:maxRunes])
|
|||
|
|
}
|