feat(imitation): accept pasted source content as an alternative to URL

- Make source_url optional and add source_content to the request; the
  two are normalized into a source kind ("url" | "content"), URL wins
  when both are present, and missing both returns source_required
- Skip web fetching in the generation worker when pasted content is
  provided; truncate it to the same 24,000-rune cap
- Label untitled pasted-content jobs with a locale-aware display title
  and mark the source kind in wizard state and prompt metadata
- Cover source normalization, truncation, and display title with tests
This commit is contained in:
2026-07-21 08:41:55 +08:00
parent 403ff91789
commit d62f7b3c91
3 changed files with 151 additions and 22 deletions
+2 -1
View File
@@ -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
@@ -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,25 +337,29 @@ 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
}
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)
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)
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"))
return
@@ -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
@@ -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)
}
}