diff --git a/apps/admin-web/src/components/ArticleActionGroup.vue b/apps/admin-web/src/components/ArticleActionGroup.vue
index 335974a..c346ebd 100644
--- a/apps/admin-web/src/components/ArticleActionGroup.vue
+++ b/apps/admin-web/src/components/ArticleActionGroup.vue
@@ -4,6 +4,7 @@ import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
+ ReloadOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
@@ -22,6 +23,11 @@ withDefaults(
showCopy?: boolean
canCopy?: boolean
copyDisabledTitle?: string
+ showRegenerate?: boolean
+ canRegenerate?: boolean
+ regenerateDisabledTitle?: string
+ regenerateConfirmTitle?: string
+ regenerateLoading?: boolean
showDelete?: boolean
canDelete?: boolean
deleteDisabledTitle?: string
@@ -41,6 +47,11 @@ withDefaults(
showCopy: false,
canCopy: true,
copyDisabledTitle: '',
+ showRegenerate: false,
+ canRegenerate: true,
+ regenerateDisabledTitle: '',
+ regenerateConfirmTitle: '',
+ regenerateLoading: false,
showDelete: true,
canDelete: true,
deleteDisabledTitle: '',
@@ -54,6 +65,7 @@ const emit = defineEmits<{
edit: []
preview: []
copy: []
+ regenerate: []
delete: []
}>()
@@ -135,6 +147,43 @@ function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: st
+
+
+
+
+
+
+
+
+
+
+
+
+
+
message.error(formatError(error)),
})
+const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
+
const columns = computed>(() => [
{ title: t('custom.article.titleColumn'), dataIndex: 'title', key: 'title', width: 340 },
{
@@ -353,13 +356,16 @@ onBeforeUnmount(() => {
diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts
index b4b7461..f9d0d8b 100644
--- a/apps/admin-web/src/i18n/messages/en-US.ts
+++ b/apps/admin-web/src/i18n/messages/en-US.ts
@@ -51,6 +51,10 @@ const enUS = {
upgrade: 'Upgrade',
copy: 'Copy',
copySuccess: 'Content copied',
+ regenerate: 'Regenerate',
+ regenerateConfirm: 'Regenerate this article?',
+ regenerateSuccess: 'Regeneration queued',
+ regenerateError: 'Regeneration failed, please try again',
yes: 'Yes',
no: 'No',
imageUploadProgress: {
diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts
index f8c6f6d..ea7bf89 100644
--- a/apps/admin-web/src/i18n/messages/zh-CN.ts
+++ b/apps/admin-web/src/i18n/messages/zh-CN.ts
@@ -51,6 +51,10 @@ const zhCN = {
upgrade: "去升级",
copy: "复制",
copySuccess: "内容已复制",
+ regenerate: "重新生成",
+ regenerateConfirm: "确认重新生成这篇文章吗?",
+ regenerateSuccess: "重新生成已提交",
+ regenerateError: "重新生成失败,请稍后重试",
yes: "是",
no: "否",
imageUploadProgress: {
diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts
index 3a9a526..3f7c567 100644
--- a/apps/admin-web/src/lib/api.ts
+++ b/apps/admin-web/src/lib/api.ts
@@ -13,16 +13,16 @@ import type {
BrandRequest,
Competitor,
CompetitorRequest,
- CreateArticleRequest,
- CreateKolPackageRequest,
- CreateKolPromptRequest,
- CreateAckRequest,
ComplianceAckRecord,
ComplianceCheckArticleRequest,
ComplianceCheckPlainRequest,
ComplianceCheckResult,
ComplianceManualReview,
ComplianceRuntimeStatus,
+ CreateAckRequest,
+ CreateArticleRequest,
+ CreateKolPackageRequest,
+ CreateKolPromptRequest,
CreatePluginSessionRequest,
CreatePluginSessionResponse,
CreatePublishBatchRequest,
@@ -92,6 +92,7 @@ import type {
QuotaSummary,
RecentArticle,
RefreshResponse,
+ RegenerateArticleResponse,
RegisterPluginInstallationRequest,
RegisterPluginInstallationResponse,
ScheduleTask,
@@ -626,6 +627,12 @@ export const articlesApi = {
payload,
)
},
+ regenerate(id: number) {
+ return apiClient.post(
+ `/api/tenant/articles/${id}/regenerate`,
+ null,
+ )
+ },
detail(id: number) {
return apiClient.get(`/api/tenant/articles/${id}`)
},
@@ -1115,7 +1122,9 @@ export const complianceApi = {
)
},
latestArticleCheck(articleId: number) {
- return apiClient.get(`/api/tenant/articles/${articleId}/compliance/latest`)
+ return apiClient.get(
+ `/api/tenant/articles/${articleId}/compliance/latest`,
+ )
},
createAck(recordId: number, payload: CreateAckRequest) {
return apiClient.post(
diff --git a/apps/admin-web/src/lib/article-list-actions.ts b/apps/admin-web/src/lib/article-list-actions.ts
index 7ead33a..a778a20 100644
--- a/apps/admin-web/src/lib/article-list-actions.ts
+++ b/apps/admin-web/src/lib/article-list-actions.ts
@@ -6,9 +6,26 @@ export interface ArticleActionState {
showPreview: boolean
showCopy: boolean
showDelete: boolean
+ showRegenerate: boolean
}
-export function resolveArticleActionState(generateStatus?: string | null): ArticleActionState {
+export interface ArticleActionSource {
+ generate_status?: string | null
+ source_type?: string | null
+}
+
+const regeneratableSourceTypes = new Set(['template', 'custom_generation', 'imitation', 'kol'])
+
+export function resolveArticleActionState(
+ articleOrGenerateStatus?: ArticleActionSource | string | null,
+): ArticleActionState {
+ const generateStatus =
+ typeof articleOrGenerateStatus === 'string'
+ ? articleOrGenerateStatus
+ : articleOrGenerateStatus?.generate_status
+ const sourceType =
+ typeof articleOrGenerateStatus === 'string' ? undefined : articleOrGenerateStatus?.source_type
+
if (generateStatus === 'completed') {
return {
showPublish: true,
@@ -16,6 +33,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: true,
showCopy: true,
showDelete: true,
+ showRegenerate: false,
}
}
@@ -26,6 +44,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: false,
showCopy: false,
showDelete: true,
+ showRegenerate: false,
}
}
@@ -36,6 +55,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: false,
showCopy: false,
showDelete: true,
+ showRegenerate: sourceType ? regeneratableSourceTypes.has(sourceType) : false,
}
}
@@ -45,6 +65,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
showPreview: true,
showCopy: false,
showDelete: true,
+ showRegenerate: false,
}
}
diff --git a/apps/admin-web/src/lib/article-regenerate-action.ts b/apps/admin-web/src/lib/article-regenerate-action.ts
new file mode 100644
index 0000000..d61b43c
--- /dev/null
+++ b/apps/admin-web/src/lib/article-regenerate-action.ts
@@ -0,0 +1,43 @@
+import type { ArticleListItem, RecentArticle } from '@geo/shared-types'
+import { useMutation, useQueryClient } from '@tanstack/vue-query'
+import { message } from 'ant-design-vue'
+import { ref } from 'vue'
+import { useI18n } from 'vue-i18n'
+
+import { articlesApi } from '@/lib/api'
+import { formatError } from '@/lib/errors'
+
+type RegeneratableArticle = Pick
+
+export function useArticleRegenerateAction() {
+ const queryClient = useQueryClient()
+ const { t } = useI18n()
+ const regeneratingArticleId = ref(null)
+
+ const regenerateMutation = useMutation({
+ mutationFn: (article: RegeneratableArticle) => articlesApi.regenerate(article.id),
+ onSuccess: async () => {
+ message.success(t('common.regenerateSuccess'))
+ regeneratingArticleId.value = null
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['articles'] }),
+ queryClient.invalidateQueries({ queryKey: ['workspace'] }),
+ ])
+ },
+ onError: (error) => {
+ regeneratingArticleId.value = null
+ message.error(formatError(error) || t('common.regenerateError'))
+ },
+ })
+
+ async function handleRegenerate(article: RegeneratableArticle): Promise {
+ regeneratingArticleId.value = article.id
+ await regenerateMutation.mutateAsync(article)
+ }
+
+ return {
+ regeneratingArticleId,
+ regenerateMutation,
+ handleRegenerate,
+ }
+}
diff --git a/apps/admin-web/src/views/FreeCreateView.vue b/apps/admin-web/src/views/FreeCreateView.vue
index b692acf..4454d85 100644
--- a/apps/admin-web/src/views/FreeCreateView.vue
+++ b/apps/admin-web/src/views/FreeCreateView.vue
@@ -16,6 +16,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
+import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { getPublishStatusMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
@@ -101,6 +102,8 @@ const createMutation = useMutation({
},
})
+const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
+
const publishStatusOptions = computed(() => [
{ label: getPublishStatusMeta('unpublished').label, value: 'unpublished' },
{ label: getPublishStatusMeta('publishing').label, value: 'publishing' },
@@ -309,13 +312,16 @@ async function copyArticle(articleId: number): Promise {
diff --git a/apps/admin-web/src/views/ImitationView.vue b/apps/admin-web/src/views/ImitationView.vue
index 73e882e..278c363 100644
--- a/apps/admin-web/src/views/ImitationView.vue
+++ b/apps/admin-web/src/views/ImitationView.vue
@@ -17,6 +17,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
+import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
@@ -99,6 +100,8 @@ const deleteMutation = useMutation({
},
})
+const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
+
const generateStatusOptions = computed(() => [
{ label: getGenerateStatusMeta('generating').label, value: 'generating' },
{ label: getGenerateStatusMeta('completed').label, value: 'completed' },
@@ -435,13 +438,16 @@ async function copyArticle(articleId: number): Promise {
diff --git a/apps/admin-web/src/views/TemplatesView.vue b/apps/admin-web/src/views/TemplatesView.vue
index 4ae876d..98f7e3b 100644
--- a/apps/admin-web/src/views/TemplatesView.vue
+++ b/apps/admin-web/src/views/TemplatesView.vue
@@ -34,6 +34,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, templatesApi, workspaceApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
+import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import {
formatDateTime,
getGenerateStatusMeta,
@@ -216,6 +217,8 @@ const deleteMutation = useMutation({
},
})
+const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
+
const templateFilterOptions = computed(() => {
return [
{ label: t('templates.sections.generalTemplates'), value: 'source:template' },
@@ -599,13 +602,16 @@ function refreshRecords(): void {
diff --git a/apps/admin-web/src/views/WorkspaceView.vue b/apps/admin-web/src/views/WorkspaceView.vue
index b6b25d2..59ec536 100644
--- a/apps/admin-web/src/views/WorkspaceView.vue
+++ b/apps/admin-web/src/views/WorkspaceView.vue
@@ -28,6 +28,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, tenantAccountsApi, workspaceApi } from '@/lib/api'
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
+import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
import { formatDateTime, getTemplateMeta } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { isMediaPublishAccount } from '@/lib/publish-platforms'
@@ -157,6 +158,8 @@ const deleteMutation = useMutation({
},
})
+const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
+
// Helper for the gradient classes based on index/type
function getTemplateCardClass(idx: number): string {
const classes = ['card-yellow', 'card-blue-light', 'card-blue-deep', 'card-grey']
@@ -451,13 +454,16 @@ function stopRecentArticlesPolling(): void {
diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts
index 5a2c1e1..9ac49ca 100644
--- a/packages/shared-types/src/index.ts
+++ b/packages/shared-types/src/index.ts
@@ -753,6 +753,11 @@ export interface GenerateImitationResponse {
task_id: number
}
+export interface RegenerateArticleResponse {
+ article_id: number
+ task_id: number
+}
+
export interface UpdateArticleRequest {
title: string
markdown_content: string
diff --git a/server/internal/tenant/app/article_imitation_service.go b/server/internal/tenant/app/article_imitation_service.go
index 062a118..115150d 100644
--- a/server/internal/tenant/app/article_imitation_service.go
+++ b/server/internal/tenant/app/article_imitation_service.go
@@ -408,7 +408,6 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
ArticleID: job.ArticleID,
- VersionNo: 1,
Title: title,
HTMLContent: "",
MarkdownContent: content,
diff --git a/server/internal/tenant/app/article_regeneration_service.go b/server/internal/tenant/app/article_regeneration_service.go
new file mode 100644
index 0000000..c938642
--- /dev/null
+++ b/server/internal/tenant/app/article_regeneration_service.go
@@ -0,0 +1,386 @@
+package app
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+
+ "github.com/geo-platform/tenant-api/internal/shared/auth"
+ "github.com/geo-platform/tenant-api/internal/shared/response"
+ "github.com/geo-platform/tenant-api/internal/tenant/repository"
+)
+
+type RegenerateArticleResponse struct {
+ ArticleID int64 `json:"article_id"`
+ TaskID int64 `json:"task_id"`
+}
+
+type articleRegenerationRecord struct {
+ ArticleID int64
+ SourceType string
+ TemplateID *int64
+ KolPromptID *int64
+ PromptRuleID *int64
+ GenerateStatus string
+ WizardStateJSON []byte
+ SourceTaskID int64
+ TaskType string
+ RequestHash *string
+ InputParamsJSON []byte
+ InputParams map[string]interface{}
+}
+
+func (s *ArticleService) Regenerate(ctx context.Context, articleID int64) (*RegenerateArticleResponse, error) {
+ actor := auth.MustActor(ctx)
+ if s == nil || s.llm == nil || s.rabbitMQ == nil {
+ return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is not configured")
+ }
+ if err := s.llm.Validate(); err != nil {
+ return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
+ }
+
+ 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")
+ }
+
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin regeneration tx: %w", err)
+ }
+ defer rollbackGenerationTx(tx)
+
+ record, err := loadArticleForRegeneration(ctx, tx, actor.TenantID, articleID)
+ if err != nil {
+ return nil, err
+ }
+ if err := validateArticleRegeneration(record); err != nil {
+ return nil, err
+ }
+
+ quotaTx := repository.NewQuotaRepository(tx)
+ auditTx := repository.NewAuditRepository(tx)
+
+ newBalance := balance - 1
+ reason := "generation_reserve"
+ referenceType := "generation_task"
+ if _, err := quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
+ TenantID: actor.TenantID,
+ OperatorID: actor.UserID,
+ QuotaType: "article_generation",
+ Delta: -1,
+ BalanceAfter: newBalance,
+ Reason: &reason,
+ ReferenceType: &referenceType,
+ }); err != nil {
+ return nil, fmt.Errorf("reserve regeneration quota: %w", err)
+ }
+
+ 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, fmt.Errorf("create regeneration reservation: %w", err)
+ }
+ if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
+ return nil, fmt.Errorf("update regeneration reservation resource: %w", err)
+ }
+
+ title := regenerationInitialTitle(record)
+ if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, title, "generating", record.WizardStateJSON); err != nil {
+ return nil, fmt.Errorf("mark article regenerating: %w", err)
+ }
+
+ requestHash := record.RequestHash
+ if requestHash == nil {
+ if hash := strings.TrimSpace(extractString(record.InputParams, "rendered_prompt_hash")); hash != "" {
+ requestHash = &hash
+ }
+ }
+
+ taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
+ OperatorID: actor.UserID,
+ TenantID: actor.TenantID,
+ ArticleID: &articleID,
+ QuotaReservationID: &reservationID,
+ TaskType: record.TaskType,
+ RequestHash: requestHash,
+ InputParamsJSON: record.InputParamsJSON,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("create regeneration task: %w", err)
+ }
+
+ if record.TaskType == kolGenerationTaskType {
+ if _, err := auditTx.CreateTaskRecord(ctx, repository.TaskRecordInput{
+ TenantID: actor.TenantID,
+ OperatorID: actor.UserID,
+ TaskType: kolGenerationTaskRecordType,
+ ResourceType: kolGenerationTaskRecordScope,
+ ResourceID: taskID,
+ Status: "queued",
+ }); err != nil {
+ return nil, fmt.Errorf("create kol regeneration task record: %w", err)
+ }
+ if err := createKolUsageForRegeneration(ctx, tx, actor.TenantID, taskID, record.InputParams); err != nil {
+ return nil, fmt.Errorf("create kol regeneration usage: %w", err)
+ }
+ }
+
+ if err := tx.Commit(ctx); err != nil {
+ return nil, fmt.Errorf("commit regeneration task: %w", err)
+ }
+ invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
+
+ task := ClaimedGenerationTask{
+ ID: taskID,
+ TenantID: actor.TenantID,
+ OperatorID: actor.UserID,
+ ArticleID: articleID,
+ QuotaReservationID: reservationID,
+ TaskType: record.TaskType,
+ InputParams: cloneInputParams(record.InputParams),
+ }
+ if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{
+ TaskID: taskID,
+ TaskType: record.TaskType,
+ TenantID: actor.TenantID,
+ ArticleID: articleID,
+ }); err != nil {
+ RecordGenerationTaskEvent(GenerationTaskEventQueueFailure, GenerationTaskLogContext{
+ TaskID: taskID,
+ TenantID: actor.TenantID,
+ OperatorID: actor.UserID,
+ ArticleID: articleID,
+ ReservationID: reservationID,
+ TaskType: record.TaskType,
+ Stage: "queue_enqueue",
+ StatusBefore: "queued",
+ StatusAfter: "failed",
+ Result: GenerationTaskResultFailure,
+ })
+ s.failRegenerationQueue(context.Background(), task, title, err)
+ return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
+ }
+
+ runtimeCfg := s.runtimeConfig()
+ if runtimeCfg.StreamEnabled && s.streams != nil {
+ s.streams.Start(articleID, taskID, title)
+ }
+
+ return &RegenerateArticleResponse{ArticleID: articleID, TaskID: taskID}, nil
+}
+
+func loadArticleForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*articleRegenerationRecord, error) {
+ record := &articleRegenerationRecord{}
+ var templateID, kolPromptID, promptRuleID sql.NullInt64
+ err := tx.QueryRow(ctx, `
+ SELECT id, source_type, template_id, kol_prompt_id, prompt_rule_id, generate_status, wizard_state_json
+ FROM articles
+ WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
+ FOR UPDATE
+ `, articleID, tenantID).Scan(
+ &record.ArticleID,
+ &record.SourceType,
+ &templateID,
+ &kolPromptID,
+ &promptRuleID,
+ &record.GenerateStatus,
+ &record.WizardStateJSON,
+ )
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
+ }
+ return nil, response.ErrInternal(50010, "query_failed", "failed to fetch article")
+ }
+ record.TemplateID = nullableSQLInt64(templateID)
+ record.KolPromptID = nullableSQLInt64(kolPromptID)
+ record.PromptRuleID = nullableSQLInt64(promptRuleID)
+
+ var requestHash sql.NullString
+ err = tx.QueryRow(ctx, `
+ SELECT id, task_type, request_hash, input_params_json
+ FROM generation_tasks
+ WHERE article_id = $1
+ AND tenant_id = $2
+ AND status = 'failed'
+ ORDER BY (COALESCE(input_params_json, '{}'::jsonb) = '{}'::jsonb), created_at DESC, id DESC
+ LIMIT 1
+ `, articleID, tenantID).Scan(
+ &record.SourceTaskID,
+ &record.TaskType,
+ &requestHash,
+ &record.InputParamsJSON,
+ )
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, response.ErrConflict(40914, "generation_task_missing", "failed generation task is missing")
+ }
+ return nil, response.ErrInternal(50010, "query_failed", "failed to fetch generation task")
+ }
+ if requestHash.Valid {
+ record.RequestHash = &requestHash.String
+ }
+ if len(record.InputParamsJSON) == 0 {
+ record.InputParamsJSON = []byte("{}")
+ }
+ params := map[string]interface{}{}
+ if err := json.Unmarshal(record.InputParamsJSON, ¶ms); err != nil {
+ return nil, response.ErrConflict(40915, "generation_params_invalid", "failed generation params are invalid")
+ }
+ record.InputParams = params
+ return record, nil
+}
+
+func validateArticleRegeneration(record *articleRegenerationRecord) error {
+ if record == nil {
+ return response.ErrNotFound(40411, "article_not_found", "article not found")
+ }
+ if record.GenerateStatus != "failed" {
+ return response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
+ }
+
+ switch record.SourceType {
+ case "template":
+ if record.TaskType == "template" && record.TemplateID != nil {
+ return nil
+ }
+ case "custom_generation":
+ if record.TaskType == "custom_generation" {
+ return nil
+ }
+ case articleImitationSourceType:
+ if record.TaskType == articleImitationTaskType {
+ return nil
+ }
+ case kolGenerationTaskType:
+ if record.TaskType == kolGenerationTaskType {
+ return nil
+ }
+ }
+
+ return response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
+}
+
+func regenerationInitialTitle(record *articleRegenerationRecord) string {
+ if record == nil {
+ return "Generated Article"
+ }
+
+ switch record.TaskType {
+ case "template":
+ return resolveArticleTitle(record.InputParams, "")
+ case "custom_generation":
+ if title := strings.TrimSpace(extractString(record.InputParams, "task_name")); title != "" {
+ return title
+ }
+ return promptRuleGeneratingTitlePlaceholder
+ case articleImitationTaskType:
+ return buildImitationInitialTitle(extractString(record.InputParams, "source_title"))
+ case kolGenerationTaskType:
+ if title := strings.TrimSpace(extractString(record.InputParams, "prompt_name")); title != "" {
+ return title
+ }
+ }
+
+ if title := strings.TrimSpace(extractString(record.InputParams, "title")); title != "" {
+ return title
+ }
+ return "Generated Article"
+}
+
+func (s *ArticleService) failRegenerationQueue(ctx context.Context, task ClaimedGenerationTask, title string, taskErr error) {
+ if task.TaskType == kolGenerationTaskType {
+ _ = HandleKolGenerationTaskFailure(ctx, s.pool, s.cache, task, "queue_enqueue", taskErr)
+ } else {
+ _ = HandleClaimedGenerationTaskFailure(ctx, s.pool, nil, nil, task, taskErr)
+ }
+
+ runtimeCfg := s.runtimeConfig()
+ if runtimeCfg.StreamEnabled && s.streams != nil {
+ s.streams.Fail(task.ArticleID, task.ID, title, formatGenerationFailure("queue_enqueue", taskErr))
+ }
+}
+
+func createKolUsageForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, taskID int64, params map[string]interface{}) error {
+ packageID := int64(extractInt(params, "package_id"))
+ promptID := int64(extractInt(params, "prompt_id"))
+ if packageID <= 0 || promptID <= 0 {
+ return nil
+ }
+
+ promptRevisionNo := extractInt(params, "prompt_revision_no")
+ if promptRevisionNo <= 0 {
+ promptRevisionNo = 1
+ }
+ subscriptionID := optionalInt64FromParams(params, "subscription_id")
+ subscriptionPromptID := optionalInt64FromParams(params, "subscription_prompt_id")
+ variablesSnapshot, err := marshalOptionalJSONField(params, "variables")
+ if err != nil {
+ return err
+ }
+ schemaSnapshot, err := marshalOptionalJSONField(params, "schema_snapshot")
+ if err != nil {
+ return err
+ }
+ renderedPromptHash := strings.TrimSpace(extractString(params, "rendered_prompt_hash"))
+ var renderedPromptHashPtr *string
+ if renderedPromptHash != "" {
+ renderedPromptHashPtr = &renderedPromptHash
+ }
+
+ _, err = repository.NewKolUsageRepository(tx).Create(ctx, repository.CreateKolUsageLogInput{
+ TenantID: tenantID,
+ SubscriptionID: subscriptionID,
+ SubscriptionPromptID: subscriptionPromptID,
+ PackageID: packageID,
+ PromptID: promptID,
+ PromptRevisionNo: promptRevisionNo,
+ GenerationTaskID: &taskID,
+ VariablesSnapshot: variablesSnapshot,
+ SchemaSnapshot: schemaSnapshot,
+ RenderedPromptHash: renderedPromptHashPtr,
+ })
+ return err
+}
+
+func marshalOptionalJSONField(params map[string]interface{}, key string) ([]byte, error) {
+ if len(params) == 0 {
+ return nil, nil
+ }
+ value, ok := params[key]
+ if !ok || value == nil {
+ return nil, nil
+ }
+ return json.Marshal(value)
+}
+
+func optionalInt64FromParams(params map[string]interface{}, key string) *int64 {
+ value := int64(extractInt(params, key))
+ if value <= 0 {
+ return nil
+ }
+ return &value
+}
+
+func nullableSQLInt64(value sql.NullInt64) *int64 {
+ if !value.Valid {
+ return nil
+ }
+ result := value.Int64
+ return &result
+}
diff --git a/server/internal/tenant/app/article_service.go b/server/internal/tenant/app/article_service.go
index 9cd6740..1bed4d4 100644
--- a/server/internal/tenant/app/article_service.go
+++ b/server/internal/tenant/app/article_service.go
@@ -27,19 +27,28 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
"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/contentstats"
+ "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/middleware"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"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/repository"
)
type ArticleService struct {
- pool *pgxpool.Pool
- auditLogs *auditlog.AsyncWriter
- objectStorage objectstorage.Client
- cache sharedcache.Cache
- cacheGroup singleflight.Group
+ pool *pgxpool.Pool
+ auditLogs *auditlog.AsyncWriter
+ objectStorage objectstorage.Client
+ llm llm.Client
+ rabbitMQ *rabbitmq.Client
+ streams *stream.GenerationHub
+ cfg generationRuntimeConfig
+ configProvider runtimeConfigProvider
+ cache sharedcache.Cache
+ cacheGroup singleflight.Group
}
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
@@ -51,6 +60,37 @@ func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService {
return s
}
+func (s *ArticleService) WithGenerationRuntime(
+ llmClient llm.Client,
+ rabbitMQClient *rabbitmq.Client,
+ streams *stream.GenerationHub,
+ cfg config.GenerationConfig,
+ llmMaxOutputTokens int64,
+) *ArticleService {
+ s.llm = llmClient
+ s.rabbitMQ = rabbitMQClient
+ s.streams = streams
+ s.cfg = generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens})
+ return s
+}
+
+func (s *ArticleService) WithConfigProvider(provider runtimeConfigProvider) *ArticleService {
+ s.configProvider = provider
+ return s
+}
+
+func (s *ArticleService) runtimeConfig() generationRuntimeConfig {
+ if s != nil && s.configProvider != nil {
+ if cfg := s.configProvider.Current(); cfg != nil {
+ return generationRuntimeFromConfig(cfg.Generation, cfg.LLM)
+ }
+ }
+ if s == nil {
+ return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{})
+ }
+ return s.cfg
+}
+
const maxArticleImageSizeBytes = 10 * 1024 * 1024
var supportedArticleImageExtensions = map[string]string{
diff --git a/server/internal/tenant/app/prompt_generate_service.go b/server/internal/tenant/app/prompt_generate_service.go
index 36c7de2..54991d2 100644
--- a/server/internal/tenant/app/prompt_generate_service.go
+++ b/server/internal/tenant/app/prompt_generate_service.go
@@ -569,7 +569,6 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
ArticleID: job.ArticleID,
- VersionNo: 1,
Title: title,
HTMLContent: "",
MarkdownContent: content,
diff --git a/server/internal/tenant/app/template_service.go b/server/internal/tenant/app/template_service.go
index 7cd85bd..af0e4e8 100644
--- a/server/internal/tenant/app/template_service.go
+++ b/server/internal/tenant/app/template_service.go
@@ -349,11 +349,11 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
AND template_id = $3
AND source_type = 'template'
AND deleted_at IS NULL
- AND generate_status = 'draft'
+ AND generate_status IN ('draft', 'failed')
FOR UPDATE
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
if err != nil {
- return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
+ return nil, response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
}
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, initialTitle, "generating", wizardStateJSON); err != nil {
return nil, fmt.Errorf("mark draft generating: %w", err)
@@ -536,7 +536,6 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
ArticleID: job.ArticleID,
- VersionNo: 1,
Title: title,
HTMLContent: "",
MarkdownContent: content,
diff --git a/server/internal/tenant/repository/article_repo.go b/server/internal/tenant/repository/article_repo.go
index 73a6b60..bada471 100644
--- a/server/internal/tenant/repository/article_repo.go
+++ b/server/internal/tenant/repository/article_repo.go
@@ -51,7 +51,25 @@ func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArtic
})
}
+func NextArticleVersionNo(ctx context.Context, db generated.DBTX, articleID int64) (int, error) {
+ var nextVersionNo int
+ err := db.QueryRow(ctx, `
+ SELECT COALESCE(MAX(version_no), 0) + 1
+ FROM article_versions
+ WHERE article_id = $1
+ `, articleID).Scan(&nextVersionNo)
+ return nextVersionNo, err
+}
+
func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) {
+ if input.VersionNo <= 0 {
+ nextVersionNo, err := NextArticleVersionNo(ctx, r.db, input.ArticleID)
+ if err != nil {
+ return 0, err
+ }
+ input.VersionNo = nextVersionNo
+ }
+
previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID)
if err != nil {
return 0, err
diff --git a/server/internal/tenant/transport/article_handler.go b/server/internal/tenant/transport/article_handler.go
index 8ea839c..d983508 100644
--- a/server/internal/tenant/transport/article_handler.go
+++ b/server/internal/tenant/transport/article_handler.go
@@ -43,7 +43,16 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
).WithConfigProvider(a.ConfigStore)
return &ArticleHandler{
- svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).WithCache(a.Cache),
+ svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).
+ WithCache(a.Cache).
+ WithGenerationRuntime(
+ a.LLM,
+ a.RabbitMQ,
+ a.GenerationStreams,
+ a.Config().Generation,
+ a.Config().LLM.MaxOutputTokens,
+ ).
+ WithConfigProvider(a.ConfigStore),
selectionOptimize: app.NewArticleSelectionOptimizeService(
a.DB,
a.LLM,
@@ -188,6 +197,22 @@ func (h *ArticleHandler) GenerateImitation(c *gin.Context) {
response.SuccessWithStatus(c, http.StatusCreated, data)
}
+func (h *ArticleHandler) Regenerate(c *gin.Context) {
+ id, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
+ return
+ }
+
+ data, err := h.svc.Regenerate(c.Request.Context(), id)
+ if err != nil {
+ response.Error(c, err)
+ return
+ }
+
+ response.SuccessWithStatus(c, http.StatusCreated, data)
+}
+
func (h *ArticleHandler) Create(c *gin.Context) {
var req app.CreateArticleRequest
if err := c.ShouldBindJSON(&req); err != nil {
diff --git a/server/internal/tenant/transport/router.go b/server/internal/tenant/transport/router.go
index 8a5d43e..ec9caf4 100644
--- a/server/internal/tenant/transport/router.go
+++ b/server/internal/tenant/transport/router.go
@@ -157,6 +157,7 @@ func RegisterRoutes(app *bootstrap.App) {
articles.POST("", artHandler.Create)
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
articles.POST("/imitations/generate", artHandler.GenerateImitation)
+ articles.POST("/:id/regenerate", artHandler.Regenerate)
articles.POST("/:id/images", artHandler.UploadImage)
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
articles.GET("/:id", artHandler.Detail)
diff --git a/server/internal/worker/generate/kol_generation_worker.go b/server/internal/worker/generate/kol_generation_worker.go
index c1bdde8..fb0b358 100644
--- a/server/internal/worker/generate/kol_generation_worker.go
+++ b/server/internal/worker/generate/kol_generation_worker.go
@@ -168,7 +168,6 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
ArticleID: task.ArticleID,
- VersionNo: 1,
Title: title,
HTMLContent: "",
MarkdownContent: content,