feat(article): support regenerating failed articles

This commit is contained in:
2026-05-07 10:55:44 +08:00
parent ff2bb77cdd
commit 7d82c5c193
21 changed files with 659 additions and 23 deletions
@@ -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
</span>
</a-tooltip>
<a-tooltip
v-if="showRegenerate && !regenerateConfirmTitle"
:title="resolveTitle(canRegenerate, t('common.regenerate'), regenerateDisabledTitle)"
>
<span>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-regenerate"
:disabled="!canRegenerate"
:loading="regenerateLoading"
@click="emit('regenerate')"
>
<ReloadOutlined />
</a-button>
</span>
</a-tooltip>
<a-popconfirm
v-else-if="showRegenerate"
:title="regenerateConfirmTitle"
:disabled="!canRegenerate"
@confirm="emit('regenerate')"
>
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-regenerate"
:disabled="!canRegenerate"
:loading="regenerateLoading"
>
<ReloadOutlined />
</a-button>
</a-popconfirm>
<a-tooltip
v-if="showDelete && !deleteConfirmTitle"
:title="resolveTitle(canDelete, t('common.delete'), deleteDisabledTitle)"
@@ -195,6 +244,11 @@ function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: st
background: #e6f4ff;
}
.action-regenerate.ant-btn:hover:not(:disabled) {
color: #fa8c16;
background: #fff7e6;
}
.action-delete.ant-btn:hover:not(:disabled) {
color: #ff4d4f;
background: #fff2f0;
@@ -15,6 +15,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
import PublishArticleModal from '@/components/PublishArticleModal.vue'
import { articlesApi, promptRulesApi } 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'
import { formatPublishPlatformList } from '@/lib/publish-platforms'
@@ -110,6 +111,8 @@ const deleteMutation = useMutation({
onError: (error) => message.error(formatError(error)),
})
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
{ title: t('custom.article.titleColumn'), dataIndex: 'title', key: 'title', width: 340 },
{
@@ -353,13 +356,16 @@ onBeforeUnmount(() => {
</template>
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openDetail(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="deleteMutation.mutate(record.id)"
/>
</template>
@@ -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: {
@@ -51,6 +51,10 @@ const zhCN = {
upgrade: "去升级",
copy: "复制",
copySuccess: "内容已复制",
regenerate: "重新生成",
regenerateConfirm: "确认重新生成这篇文章吗?",
regenerateSuccess: "重新生成已提交",
regenerateError: "重新生成失败,请稍后重试",
yes: "是",
no: "否",
imageUploadProgress: {
+14 -5
View File
@@ -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<RegenerateArticleResponse, null>(
`/api/tenant/articles/${id}/regenerate`,
null,
)
},
detail(id: number) {
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`)
},
@@ -1115,7 +1122,9 @@ export const complianceApi = {
)
},
latestArticleCheck(articleId: number) {
return apiClient.get<ComplianceCheckResult>(`/api/tenant/articles/${articleId}/compliance/latest`)
return apiClient.get<ComplianceCheckResult>(
`/api/tenant/articles/${articleId}/compliance/latest`,
)
},
createAck(recordId: number, payload: CreateAckRequest) {
return apiClient.post<ComplianceAckRecord, CreateAckRequest>(
+22 -1
View File
@@ -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,
}
}
@@ -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<ArticleListItem | RecentArticle, 'id'>
export function useArticleRegenerateAction() {
const queryClient = useQueryClient()
const { t } = useI18n()
const regeneratingArticleId = ref<number | null>(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<void> {
regeneratingArticleId.value = article.id
await regenerateMutation.mutateAsync(article)
}
return {
regeneratingArticleId,
regenerateMutation,
handleRegenerate,
}
}
+7 -1
View File
@@ -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<void> {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('freeCreate.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openPreview(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+7 -1
View File
@@ -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<void> {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('imitation.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openPreview(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+7 -1
View File
@@ -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 {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record)"
@edit="openEditor(record)"
@preview="openPreview(record)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+7 -1
View File
@@ -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 {
<template v-else-if="column.key === 'actions'">
<ArticleActionGroup
v-bind="resolveArticleActionState(record.generate_status)"
v-bind="resolveArticleActionState(record)"
:delete-confirm-title="t('templates.list.deleteConfirm')"
:delete-loading="deleteMutation.isPending.value"
:regenerate-confirm-title="t('common.regenerateConfirm')"
:regenerate-loading="regeneratingArticleId === record.id"
@publish="openPublish(record.id)"
@edit="openEditor(record)"
@preview="openArticle(record.id)"
@copy="copyArticle(record.id)"
@regenerate="handleRegenerate(record)"
@delete="handleDelete(record.id)"
/>
</template>
+5
View File
@@ -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
@@ -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,
@@ -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, &params); 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
}
+45 -5
View File
@@ -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{
@@ -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,
@@ -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,
@@ -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
@@ -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 {
@@ -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)
@@ -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,