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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user