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
|
||||
|
||||
Reference in New Issue
Block a user