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,