feat: Refactor template handling and brand management

- Removed schema_json from article templates and related queries.
- Updated brand service to include website field in requests and responses.
- Simplified template wizard view by eliminating unused schema fields and related logic.
- Added tests for ark text format handling.
- Created migrations for dropping schema_json and adding website to brands.
This commit is contained in:
2026-04-02 11:38:08 +08:00
parent 7fa1809682
commit 111498a65f
25 changed files with 298 additions and 379 deletions
+33 -25
View File
@@ -132,31 +132,7 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f
},
}
var textFormat *responses.ResponsesText
if req.ResponseFormat != nil {
format := &responses.TextFormat{
Name: strings.TrimSpace(req.ResponseFormat.Name),
}
switch req.ResponseFormat.Type {
case ResponseFormatTypeJSONSchema:
format.Type = responses.TextType_json_schema
case ResponseFormatTypeJSONObject:
format.Type = responses.TextType_json_object
default:
format.Type = responses.TextType_text
}
if description := strings.TrimSpace(req.ResponseFormat.Description); description != "" {
format.Description = &description
}
if len(req.ResponseFormat.SchemaJSON) > 0 {
format.Schema = &responses.Bytes{Value: req.ResponseFormat.SchemaJSON}
}
if req.ResponseFormat.Strict {
strict := true
format.Strict = &strict
}
textFormat = &responses.ResponsesText{Format: format}
}
textFormat := buildArkTextFormat(req.ResponseFormat)
var tools []*responses.ResponsesTool
if req.WebSearch != nil && req.WebSearch.Enabled {
@@ -260,6 +236,38 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f
}, nil
}
func buildArkTextFormat(format *ResponseFormat) *responses.ResponsesText {
if format == nil {
return nil
}
textFormat := &responses.TextFormat{}
switch format.Type {
case ResponseFormatTypeJSONSchema:
textFormat.Type = responses.TextType_json_schema
if name := strings.TrimSpace(format.Name); name != "" {
textFormat.Name = name
}
if description := strings.TrimSpace(format.Description); description != "" {
textFormat.Description = &description
}
if len(format.SchemaJSON) > 0 {
textFormat.Schema = &responses.Bytes{Value: format.SchemaJSON}
}
if format.Strict {
strict := true
textFormat.Strict = &strict
}
case ResponseFormatTypeJSONObject:
// Ark only accepts the type discriminator for json_object.
textFormat.Type = responses.TextType_json_object
default:
textFormat.Type = responses.TextType_text
}
return &responses.ResponsesText{Format: textFormat}
}
func resolveArkReasoning(value string) *responses.ResponsesReasoning {
switch strings.ToLower(strings.TrimSpace(value)) {
case "":
+65
View File
@@ -0,0 +1,65 @@
package llm
import (
"testing"
"github.com/volcengine/volcengine-go-sdk/service/arkruntime/model/responses"
)
func TestBuildArkTextFormatJSONObjectDropsSchemaFields(t *testing.T) {
text := buildArkTextFormat(&ResponseFormat{
Type: ResponseFormatTypeJSONObject,
Name: "article_outline",
Description: "outline object",
SchemaJSON: []byte(`{"type":"object"}`),
Strict: true,
})
if text == nil || text.Format == nil {
t.Fatal("buildArkTextFormat() returned nil")
}
if got := text.Format.Type; got != responses.TextType_json_object {
t.Fatalf("buildArkTextFormat() type = %v, want %v", got, responses.TextType_json_object)
}
if text.Format.Name != "" {
t.Fatalf("buildArkTextFormat() name = %q, want empty for json_object", text.Format.Name)
}
if text.Format.Description != nil {
t.Fatalf("buildArkTextFormat() description = %v, want nil for json_object", *text.Format.Description)
}
if text.Format.Schema != nil {
t.Fatalf("buildArkTextFormat() schema = %v, want nil for json_object", text.Format.Schema)
}
if text.Format.Strict != nil {
t.Fatalf("buildArkTextFormat() strict = %v, want nil for json_object", *text.Format.Strict)
}
}
func TestBuildArkTextFormatJSONSchemaKeepsSchemaFields(t *testing.T) {
text := buildArkTextFormat(&ResponseFormat{
Type: ResponseFormatTypeJSONSchema,
Name: "article_outline",
Description: "outline object",
SchemaJSON: []byte(`{"type":"object"}`),
Strict: true,
})
if text == nil || text.Format == nil {
t.Fatal("buildArkTextFormat() returned nil")
}
if got := text.Format.Type; got != responses.TextType_json_schema {
t.Fatalf("buildArkTextFormat() type = %v, want %v", got, responses.TextType_json_schema)
}
if text.Format.Name != "article_outline" {
t.Fatalf("buildArkTextFormat() name = %q, want article_outline", text.Format.Name)
}
if text.Format.Description == nil || *text.Format.Description != "outline object" {
t.Fatalf("buildArkTextFormat() description = %v, want outline object", text.Format.Description)
}
if text.Format.Schema == nil || string(text.Format.Schema.Value) != `{"type":"object"}` {
t.Fatalf("buildArkTextFormat() schema = %v, want schema payload", text.Format.Schema)
}
if text.Format.Strict == nil || !*text.Format.Strict {
t.Fatalf("buildArkTextFormat() strict = %v, want true", text.Format.Strict)
}
}
+42 -11
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
@@ -27,12 +28,14 @@ func NewBrandService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter) *Brand
type BrandRequest struct {
Name string `json:"name" binding:"required"`
Website *string `json:"website"`
Description *string `json:"description"`
}
type BrandResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Website *string `json:"website"`
Description *string `json:"description"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
@@ -42,7 +45,7 @@ type BrandResponse struct {
func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
actor := auth.MustActor(ctx)
rows, err := s.pool.Query(ctx, `
SELECT id, name, description, status, created_at, updated_at
SELECT id, name, website, description, status, created_at, updated_at
FROM brands WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC
`, actor.TenantID)
if err != nil {
@@ -54,7 +57,7 @@ func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
for rows.Next() {
var b BrandResponse
var ca, ua interface{}
if err := rows.Scan(&b.ID, &b.Name, &b.Description, &b.Status, &ca, &ua); err != nil {
if err := rows.Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
b.CreatedAt = fmt.Sprintf("%v", ca)
@@ -69,12 +72,16 @@ func (s *BrandService) List(ctx context.Context) ([]BrandResponse, error) {
func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResponse, error) {
actor := auth.MustActor(ctx)
normalizeBrandRequest(&req)
if req.Name == "" {
return nil, response.ErrBadRequest(40001, "invalid_params", "name is required")
}
var id int64
var ca interface{}
err := s.pool.QueryRow(ctx, `
INSERT INTO brands (tenant_id, name, description, status)
VALUES ($1, $2, $3, 'active') RETURNING id, created_at
`, actor.TenantID, req.Name, req.Description).Scan(&id, &ca)
INSERT INTO brands (tenant_id, name, website, description, status)
VALUES ($1, $2, $3, $4, 'active') RETURNING id, created_at
`, actor.TenantID, req.Name, req.Website, req.Description).Scan(&id, &ca)
if err != nil {
return nil, response.ErrConflict(40901, "brand_exists", "brand with this name already exists")
}
@@ -95,7 +102,14 @@ func (s *BrandService) Create(ctx context.Context, req BrandRequest) (*BrandResp
Result: &result,
})
return &BrandResponse{ID: id, Name: req.Name, Description: req.Description, Status: "active", CreatedAt: fmt.Sprintf("%v", ca)}, nil
return &BrandResponse{
ID: id,
Name: req.Name,
Website: req.Website,
Description: req.Description,
Status: "active",
CreatedAt: fmt.Sprintf("%v", ca),
}, nil
}
func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, error) {
@@ -103,9 +117,9 @@ func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, er
var b BrandResponse
var ca, ua interface{}
err := s.pool.QueryRow(ctx, `
SELECT id, name, description, status, created_at, updated_at
SELECT id, name, website, description, status, created_at, updated_at
FROM brands WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
`, id, actor.TenantID).Scan(&b.ID, &b.Name, &b.Description, &b.Status, &ca, &ua)
`, id, actor.TenantID).Scan(&b.ID, &b.Name, &b.Website, &b.Description, &b.Status, &ca, &ua)
if err != nil {
return nil, response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
@@ -116,16 +130,33 @@ func (s *BrandService) Detail(ctx context.Context, id int64) (*BrandResponse, er
func (s *BrandService) Update(ctx context.Context, id int64, req BrandRequest) error {
actor := auth.MustActor(ctx)
normalizeBrandRequest(&req)
if req.Name == "" {
return response.ErrBadRequest(40001, "invalid_params", "name is required")
}
tag, err := s.pool.Exec(ctx, `
UPDATE brands SET name = $1, description = $2, updated_at = NOW()
WHERE id = $3 AND tenant_id = $4 AND deleted_at IS NULL
`, req.Name, req.Description, id, actor.TenantID)
UPDATE brands SET name = $1, website = $2, description = $3, updated_at = NOW()
WHERE id = $4 AND tenant_id = $5 AND deleted_at IS NULL
`, req.Name, req.Website, req.Description, id, actor.TenantID)
if err != nil || tag.RowsAffected() == 0 {
return response.ErrNotFound(40420, "brand_not_found", "brand not found")
}
return nil
}
func normalizeBrandRequest(req *BrandRequest) {
req.Name = strings.TrimSpace(req.Name)
req.Website = normalizeOptionalString(req.Website)
req.Description = normalizeOptionalString(req.Description)
}
func normalizeOptionalString(value *string) *string {
if value == nil {
return nil
}
return nilIfEmptyString(*value)
}
func (s *BrandService) Delete(ctx context.Context, id int64) error {
actor := auth.MustActor(ctx)
tx, err := s.pool.Begin(ctx)
+1 -16
View File
@@ -93,7 +93,6 @@ type TemplateListItem struct {
OriginType string `json:"origin_type"`
TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"`
SchemaJSON map[string]interface{} `json:"schema_json"`
PromptVisibility string `json:"prompt_visibility"`
CardConfigJSON map[string]interface{} `json:"card_config"`
Status string `json:"status"`
@@ -122,7 +121,6 @@ func (s *TemplateService) List(ctx context.Context) ([]TemplateListItem, error)
VersionNo: row.VersionNo,
CreatedAt: row.CreatedAt,
}
_ = json.Unmarshal(row.SchemaJSON, &item.SchemaJSON)
_ = json.Unmarshal(row.CardConfigJSON, &item.CardConfigJSON)
items = append(items, item)
}
@@ -142,7 +140,7 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
}
detail := &TemplateDetail{
detail := &TemplateDetail{
TemplateListItem: TemplateListItem{
ID: record.ID,
Scope: record.Scope,
@@ -156,7 +154,6 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
CreatedAt: record.CreatedAt,
},
}
_ = json.Unmarshal(record.SchemaJSON, &detail.SchemaJSON)
_ = json.Unmarshal(record.CardConfigJSON, &detail.CardConfigJSON)
if canViewPromptTemplate(record, actor.TenantID) {
@@ -166,18 +163,6 @@ func (s *TemplateService) Detail(ctx context.Context, id int64) (*TemplateDetail
return detail, nil
}
func (s *TemplateService) Schema(ctx context.Context, id int64) (map[string]interface{}, error) {
actor := auth.MustActor(ctx)
record, err := s.templates.GetTemplateByID(ctx, id, actor.TenantID)
if err != nil {
return nil, response.ErrNotFound(40410, "template_not_found", "template not found")
}
schema := map[string]interface{}{}
_ = json.Unmarshal(record.SchemaJSON, &schema)
return schema, nil
}
func canViewPromptTemplate(record *repository.TemplateRecord, actorTenantID int64) bool {
if record == nil || record.TenantID == nil {
return false
@@ -9,7 +9,6 @@ type ArticleTemplate struct {
OriginType string
TemplateKey string
TemplateName string
SchemaJSON map[string]interface{}
PromptTemplate *string
PromptVisibility string
ProtectedPromptAssetKey *string
+1
View File
@@ -6,6 +6,7 @@ type Brand struct {
ID int64
TenantID int64
Name string
Website *string
Description *string
Status string
CreatedAt time.Time
@@ -31,7 +31,6 @@ type ArticleTemplate struct {
ShareCodeID pgtype.Int8 `json:"share_code_id"`
TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
PromptTemplate pgtype.Text `json:"prompt_template"`
PromptVisibility string `json:"prompt_visibility"`
ProtectedPromptAssetKey pgtype.Text `json:"protected_prompt_asset_key"`
@@ -42,7 +42,6 @@ type Querier interface {
GetRecentArticles(ctx context.Context, tenantID int64) ([]GetRecentArticlesRow, error)
GetScheduleTaskByID(ctx context.Context, arg GetScheduleTaskByIDParams) (GetScheduleTaskByIDRow, error)
GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error)
GetTemplateSchema(ctx context.Context, id int64) (GetTemplateSchemaRow, error)
GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error)
GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -13,7 +13,7 @@ import (
const getTemplateByID = `-- name: GetTemplateByID :one
SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, protected_prompt_asset_key,
prompt_template, prompt_visibility, protected_prompt_asset_key,
card_config_json, status, version_no, created_at, updated_at
FROM article_templates
WHERE id = $1
@@ -33,7 +33,6 @@ type GetTemplateByIDRow struct {
OriginType string `json:"origin_type"`
TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
PromptTemplate pgtype.Text `json:"prompt_template"`
PromptVisibility string `json:"prompt_visibility"`
ProtectedPromptAssetKey pgtype.Text `json:"protected_prompt_asset_key"`
@@ -54,7 +53,6 @@ func (q *Queries) GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams
&i.OriginType,
&i.TemplateKey,
&i.TemplateName,
&i.SchemaJson,
&i.PromptTemplate,
&i.PromptVisibility,
&i.ProtectedPromptAssetKey,
@@ -67,28 +65,9 @@ func (q *Queries) GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams
return i, err
}
const getTemplateSchema = `-- name: GetTemplateSchema :one
SELECT id, template_name, schema_json
FROM article_templates
WHERE id = $1 AND deleted_at IS NULL
`
type GetTemplateSchemaRow struct {
ID int64 `json:"id"`
TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
}
func (q *Queries) GetTemplateSchema(ctx context.Context, id int64) (GetTemplateSchemaRow, error) {
row := q.db.QueryRow(ctx, getTemplateSchema, id)
var i GetTemplateSchemaRow
err := row.Scan(&i.ID, &i.TemplateName, &i.SchemaJson)
return i, err
}
const listTemplates = `-- name: ListTemplates :many
SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, card_config_json,
prompt_template, prompt_visibility, card_config_json,
status, version_no, created_at, updated_at
FROM article_templates
WHERE (scope = 'platform' OR tenant_id = $1::bigint)
@@ -103,7 +82,6 @@ type ListTemplatesRow struct {
OriginType string `json:"origin_type"`
TemplateKey string `json:"template_key"`
TemplateName string `json:"template_name"`
SchemaJson []byte `json:"schema_json"`
PromptTemplate pgtype.Text `json:"prompt_template"`
PromptVisibility string `json:"prompt_visibility"`
CardConfigJson []byte `json:"card_config_json"`
@@ -129,7 +107,6 @@ func (q *Queries) ListTemplates(ctx context.Context, tenantID int64) ([]ListTemp
&i.OriginType,
&i.TemplateKey,
&i.TemplateName,
&i.SchemaJson,
&i.PromptTemplate,
&i.PromptVisibility,
&i.CardConfigJson,
@@ -1,6 +1,6 @@
-- name: ListTemplates :many
SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, card_config_json,
prompt_template, prompt_visibility, card_config_json,
status, version_no, created_at, updated_at
FROM article_templates
WHERE (scope = 'platform' OR tenant_id = sqlc.arg(tenant_id)::bigint)
@@ -9,14 +9,9 @@ ORDER BY created_at DESC;
-- name: GetTemplateByID :one
SELECT id, scope, tenant_id, origin_type, template_key, template_name,
schema_json, prompt_template, prompt_visibility, protected_prompt_asset_key,
prompt_template, prompt_visibility, protected_prompt_asset_key,
card_config_json, status, version_no, created_at, updated_at
FROM article_templates
WHERE id = sqlc.arg(id)
AND (scope = 'platform' OR tenant_id = sqlc.arg(tenant_id)::bigint)
AND deleted_at IS NULL;
-- name: GetTemplateSchema :one
SELECT id, template_name, schema_json
FROM article_templates
WHERE id = sqlc.arg(id) AND deleted_at IS NULL;
@@ -61,7 +61,3 @@ func (r *cachedTemplateRepository) GetTemplateByID(ctx context.Context, id, tena
}
return record, nil
}
func (r *cachedTemplateRepository) GetTemplateSchema(ctx context.Context, id int64) (*TemplateSchemaRecord, error) {
return r.inner.GetTemplateSchema(ctx, id)
}
@@ -14,7 +14,6 @@ type TemplateRecord struct {
OriginType string
TemplateKey string
TemplateName string
SchemaJSON []byte
PromptTemplate *string
PromptVisibility string
ProtectedPromptAssetKey *string
@@ -25,16 +24,9 @@ type TemplateRecord struct {
UpdatedAt time.Time
}
type TemplateSchemaRecord struct {
ID int64
TemplateName string
SchemaJSON []byte
}
type TemplateRepository interface {
ListTemplates(ctx context.Context, tenantID int64) ([]TemplateRecord, error)
GetTemplateByID(ctx context.Context, id, tenantID int64) (*TemplateRecord, error)
GetTemplateSchema(ctx context.Context, id int64) (*TemplateSchemaRecord, error)
}
type templateRepository struct {
@@ -60,7 +52,6 @@ func (r *templateRepository) ListTemplates(ctx context.Context, tenantID int64)
OriginType: row.OriginType,
TemplateKey: row.TemplateKey,
TemplateName: row.TemplateName,
SchemaJSON: row.SchemaJson,
PromptTemplate: nullableText(row.PromptTemplate),
PromptVisibility: row.PromptVisibility,
CardConfigJSON: row.CardConfigJson,
@@ -90,7 +81,6 @@ func (r *templateRepository) GetTemplateByID(ctx context.Context, id, tenantID i
OriginType: row.OriginType,
TemplateKey: row.TemplateKey,
TemplateName: row.TemplateName,
SchemaJSON: row.SchemaJson,
PromptTemplate: nullableText(row.PromptTemplate),
PromptVisibility: row.PromptVisibility,
ProtectedPromptAssetKey: nullableText(row.ProtectedPromptAssetKey),
@@ -101,16 +91,3 @@ func (r *templateRepository) GetTemplateByID(ctx context.Context, id, tenantID i
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
}
func (r *templateRepository) GetTemplateSchema(ctx context.Context, id int64) (*TemplateSchemaRecord, error) {
row, err := r.q.GetTemplateSchema(ctx, id)
if err != nil {
return nil, err
}
return &TemplateSchemaRecord{
ID: row.ID,
TemplateName: row.TemplateName,
SchemaJSON: row.SchemaJson,
}, nil
}
@@ -30,7 +30,6 @@ func RegisterRoutes(app *bootstrap.App) {
tplHandler := NewTemplateHandler(app)
templates.GET("", tplHandler.List)
templates.GET("/:id", tplHandler.Detail)
templates.GET("/:id/schema", tplHandler.Schema)
templates.POST("/:id/drafts", tplHandler.SaveDraft)
templates.POST("/:id/analyze-tasks", tplHandler.CreateAnalyzeTask)
templates.GET("/:id/analyze_task_result", tplHandler.GetAnalyzeTaskResult)
@@ -54,20 +54,6 @@ func (h *TemplateHandler) Detail(c *gin.Context) {
response.Success(c, data)
}
func (h *TemplateHandler) Schema(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "template id must be a number"))
return
}
data, err := h.svc.Schema(c.Request.Context(), id)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *TemplateHandler) Generate(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {