diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 13a04ad..866be95 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1105,7 +1105,8 @@ export interface CreateArticleRequest { } export interface GenerateImitationRequest { - source_url: string + source_url?: string + source_content?: string source_title?: string locale?: string brand_name: string diff --git a/server/internal/tenant/app/article_imitation_service.go b/server/internal/tenant/app/article_imitation_service.go index c40ed81..dbb4530 100644 --- a/server/internal/tenant/app/article_imitation_service.go +++ b/server/internal/tenant/app/article_imitation_service.go @@ -26,6 +26,8 @@ import ( const ( articleImitationTaskType = "imitation" articleImitationSourceType = "imitation" + articleImitationSourceURL = "url" + articleImitationSourceContent = "content" articleImitationFallbackTitle = "仿写文章生成中" maxImitationSourceContentRunes = 24000 defaultImitationWebSearchLimit = int32(5) @@ -44,7 +46,8 @@ type ArticleImitationService struct { } type GenerateImitationRequest struct { - SourceURL string `json:"source_url" binding:"required"` + SourceURL string `json:"source_url"` + SourceContent string `json:"source_content"` SourceTitle string `json:"source_title"` Locale string `json:"locale"` BrandName string `json:"brand_name"` @@ -130,7 +133,7 @@ func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImit return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error()) } - sourceURL, err := normalizeImitationSourceURL(req.SourceURL) + sourceURL, sourceContent, sourceKind, err := normalizeImitationSource(req) if err != nil { return nil, err } @@ -148,15 +151,17 @@ func (s *ArticleImitationService) Generate(ctx context.Context, req GenerateImit return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan") } - inputParams := buildImitationInputParams(sourceURL, req) + inputParams := buildImitationInputParams(sourceURL, sourceContent, sourceKind, req) inputParams["brand_id"] = brandID inputParams = attachBrandPromptContext(inputParams, brandPromptContext) inputJSON, _ := json.Marshal(inputParams) initialTitle := buildImitationInitialTitle(req.SourceTitle) + sourceDisplayTitle := imitationSourceDisplayTitle(sourceKind, req.SourceTitle, req.Locale) wizardStateJSON, _ := json.Marshal(map[string]interface{}{ "title": initialTitle, "source_url": sourceURL, - "source_title": strings.TrimSpace(req.SourceTitle), + "source_kind": sourceKind, + "source_title": sourceDisplayTitle, "current_step": 0, }) @@ -332,24 +337,28 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art job.InputParams = attachBrandPromptContext(job.InputParams, brandPromptContext) 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 - } + sourceContent := "" + if sourceURL != "" { + 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 - } + parsed, parseErr := s.knowledge.parseWebsiteKnowledge(ctx, sourceURL) + if parseErr != nil { + s.failGeneration(ctx, job, "source_fetch", parseErr) + return + } - sourceContent := truncateRunes(strings.TrimSpace(parsed.Markdown), maxImitationSourceContentRunes) - if sourceContent == "" { - sourceContent = truncateRunes(strings.TrimSpace(parsed.Text), maxImitationSourceContentRunes) + sourceContent = truncateRunes(strings.TrimSpace(parsed.Markdown), maxImitationSourceContentRunes) + if sourceContent == "" { + sourceContent = truncateRunes(strings.TrimSpace(parsed.Text), maxImitationSourceContentRunes) + } + } else { + sourceContent = truncateRunes( + strings.TrimSpace(extractString(job.InputParams, "source_content")), + maxImitationSourceContentRunes, + ) } if sourceContent == "" { s.failGeneration(ctx, job, "source_fetch", fmt.Errorf("source article content is empty")) @@ -625,9 +634,27 @@ func normalizeImitationSourceURL(raw string) (string, error) { return parsed.String(), nil } -func buildImitationInputParams(sourceURL string, req GenerateImitationRequest) map[string]interface{} { +func normalizeImitationSource(req GenerateImitationRequest) (string, string, string, error) { + if strings.TrimSpace(req.SourceURL) != "" { + sourceURL, err := normalizeImitationSourceURL(req.SourceURL) + if err != nil { + return "", "", "", err + } + return sourceURL, "", articleImitationSourceURL, nil + } + + sourceContent := truncateRunes(strings.TrimSpace(req.SourceContent), maxImitationSourceContentRunes) + if sourceContent == "" { + return "", "", "", response.ErrBadRequest(40001, "source_required", "source_url or source_content is required") + } + return "", sourceContent, articleImitationSourceContent, nil +} + +func buildImitationInputParams(sourceURL, sourceContent, sourceKind string, req GenerateImitationRequest) map[string]interface{} { params := map[string]interface{}{ "source_url": sourceURL, + "source_content": sourceContent, + "source_kind": sourceKind, "source_title": strings.TrimSpace(req.SourceTitle), "locale": normalizeImitationLocale(req.Locale), "brand_name": strings.TrimSpace(req.BrandName), @@ -654,10 +681,22 @@ func buildImitationInitialTitle(sourceTitle string) string { return "仿写:" + title } +func imitationSourceDisplayTitle(sourceKind, sourceTitle, locale string) string { + title := strings.TrimSpace(sourceTitle) + if title != "" || sourceKind != articleImitationSourceContent { + return title + } + if normalizeImitationLocale(locale) == "en-US" { + return "Pasted content" + } + return "粘贴正文" +} + 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")) + sourceKind := strings.TrimSpace(extractString(params, "source_kind")) keywords := extractStringList(params["keywords"], 16) knowledgePrompt = strings.TrimSpace(knowledgePrompt) @@ -667,6 +706,8 @@ func buildImitationGenerationPrompt(params map[string]interface{}, sourceContent } if sourceURL != "" { appendRawPromptLine(&sourceMeta, "URL", sourceURL) + } else if sourceKind == articleImitationSourceContent { + appendRawPromptLine(&sourceMeta, "来源", "用户粘贴正文") } var settings strings.Builder diff --git a/server/internal/tenant/app/article_imitation_service_test.go b/server/internal/tenant/app/article_imitation_service_test.go new file mode 100644 index 0000000..2838eab --- /dev/null +++ b/server/internal/tenant/app/article_imitation_service_test.go @@ -0,0 +1,87 @@ +package app + +import ( + "strings" + "testing" +) + +func TestNormalizeImitationSourcePrefersURL(t *testing.T) { + t.Parallel() + + sourceURL, sourceContent, sourceKind, err := normalizeImitationSource(GenerateImitationRequest{ + SourceURL: " https://example.com/article ", + SourceContent: "pasted content should be ignored", + }) + if err != nil { + t.Fatalf("normalizeImitationSource() error = %v", err) + } + if sourceURL != "https://example.com/article" { + t.Fatalf("sourceURL = %q, want normalized URL", sourceURL) + } + if sourceContent != "" { + t.Fatalf("sourceContent = %q, want empty when URL is present", sourceContent) + } + if sourceKind != articleImitationSourceURL { + t.Fatalf("sourceKind = %q, want %q", sourceKind, articleImitationSourceURL) + } +} + +func TestNormalizeImitationSourceAcceptsPastedContent(t *testing.T) { + t.Parallel() + + sourceURL, sourceContent, sourceKind, err := normalizeImitationSource(GenerateImitationRequest{ + SourceContent: " article body ", + }) + if err != nil { + t.Fatalf("normalizeImitationSource() error = %v", err) + } + if sourceURL != "" { + t.Fatalf("sourceURL = %q, want empty", sourceURL) + } + if sourceContent != "article body" { + t.Fatalf("sourceContent = %q, want trimmed content", sourceContent) + } + if sourceKind != articleImitationSourceContent { + t.Fatalf("sourceKind = %q, want %q", sourceKind, articleImitationSourceContent) + } +} + +func TestNormalizeImitationSourceRejectsMissingSource(t *testing.T) { + t.Parallel() + + _, _, _, err := normalizeImitationSource(GenerateImitationRequest{}) + if err == nil { + t.Fatal("normalizeImitationSource() error = nil, want source_required") + } + if err.Error() != "source_required" { + t.Fatalf("normalizeImitationSource() error = %q, want source_required", err.Error()) + } +} + +func TestNormalizeImitationSourceTruncatesPastedContent(t *testing.T) { + t.Parallel() + + _, sourceContent, _, err := normalizeImitationSource(GenerateImitationRequest{ + SourceContent: strings.Repeat("文", maxImitationSourceContentRunes+100), + }) + if err != nil { + t.Fatalf("normalizeImitationSource() error = %v", err) + } + if got := len([]rune(sourceContent)); got != maxImitationSourceContentRunes { + t.Fatalf("source content rune count = %d, want %d", got, maxImitationSourceContentRunes) + } +} + +func TestImitationSourceDisplayTitleLabelsUntitledPastedContent(t *testing.T) { + t.Parallel() + + if got := imitationSourceDisplayTitle(articleImitationSourceContent, "", "zh-CN"); got != "粘贴正文" { + t.Fatalf("Chinese display title = %q, want 粘贴正文", got) + } + if got := imitationSourceDisplayTitle(articleImitationSourceContent, "", "en-US"); got != "Pasted content" { + t.Fatalf("English display title = %q, want Pasted content", got) + } + if got := imitationSourceDisplayTitle(articleImitationSourceContent, "Original title", "en-US"); got != "Original title" { + t.Fatalf("explicit display title = %q, want Original title", got) + } +}