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
+1 -1
View File
@@ -7,4 +7,4 @@ dist/
.env.local
apps/*/dist/
apps/*/node_modules/
.claude
@@ -447,6 +447,7 @@ const enUS = {
},
form: {
brandName: "Brand name",
brandWebsite: "Company website (optional)",
brandDescription: "Brand description",
keywordName: "Keyword",
questionText: "Question text",
@@ -454,6 +454,7 @@ const zhCN = {
},
form: {
brandName: "品牌名称",
brandWebsite: "公司官网(选填)",
brandDescription: "品牌描述",
keywordName: "关键词名称",
questionText: "问题内容",
+122 -57
View File
@@ -12,7 +12,6 @@ import type { TableColumnsType } from "ant-design-vue";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import PageHero from "@/components/PageHero.vue";
import { brandsApi } from "@/lib/api";
import { formatError } from "@/lib/errors";
@@ -36,6 +35,7 @@ const editingCompetitorId = ref<number | null>(null);
const brandForm = reactive({
name: "",
website: "",
description: "",
});
@@ -60,12 +60,6 @@ const brandListQuery = useQuery({
queryFn: () => brandsApi.list(),
});
const brandDetailQuery = useQuery({
queryKey: computed(() => ["brands", "detail", selectedBrandId.value]),
enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => brandsApi.detail(selectedBrandId.value as number),
});
const keywordsQuery = useQuery({
queryKey: computed(() => ["brands", selectedBrandId.value, "keywords"]),
enabled: computed(() => Boolean(selectedBrandId.value)),
@@ -101,6 +95,7 @@ const brandMutations = {
mutationFn: () =>
brandsApi.create({
name: brandForm.name.trim(),
website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null,
}),
onSuccess: async (brand) => {
@@ -118,6 +113,7 @@ const brandMutations = {
mutationFn: () =>
brandsApi.update(editingBrandId.value as number, {
name: brandForm.name.trim(),
website: brandForm.website.trim() || null,
description: brandForm.description.trim() || null,
}),
onSuccess: async () => {
@@ -274,7 +270,6 @@ const competitorMutations = {
}),
};
const selectedBrand = computed(() => brandDetailQuery.data.value);
const selectedKeyword = computed(() =>
keywordsQuery.data.value?.find((item) => item.id === selectedKeywordId.value) ?? null,
);
@@ -345,6 +340,7 @@ function stringifyProductLines(value: unknown): string {
function openBrandModal(brand?: Brand): void {
editingBrandId.value = brand?.id ?? null;
brandForm.name = brand?.name ?? "";
brandForm.website = brand?.website ?? "";
brandForm.description = brand?.description ?? "";
brandModalOpen.value = true;
}
@@ -439,18 +435,21 @@ async function submitCompetitor(): Promise<void> {
<template>
<div class="brands-view">
<PageHero
:eyebrow="t('brands.eyebrow')"
:title="t('brands.title')"
:description="t('brands.description')"
>
<template #actions>
<a-button type="primary" @click="openBrandModal()">
<template #icon><PlusOutlined /></template>
{{ t("brands.newBrand") }}
</a-button>
</template>
</PageHero>
<section class="brands-view__top-card">
<div class="brands-view__header">
<div class="brands-view__header-title">
<p class="eyebrow" style="margin-bottom: 4px;">{{ t('brands.eyebrow') }}</p>
<h2>{{ t('brands.title') }}</h2>
<p>{{ t('brands.description') }}</p>
</div>
<div class="brands-view__header-actions">
<a-button type="primary" @click="openBrandModal()">
<template #icon><PlusOutlined /></template>
{{ t("brands.newBrand") }}
</a-button>
</div>
</div>
</section>
<section class="brands-view__brand-rail">
<div class="brands-view__section-head">
@@ -491,19 +490,7 @@ async function submitCompetitor(): Promise<void> {
<a-empty v-else :description="t('brands.empty.brands')" />
</section>
<template v-if="selectedBrandId && selectedBrand">
<section class="brands-view__brand-summary">
<div>
<p class="eyebrow">{{ t("brands.title") }}</p>
<h3>{{ selectedBrand.name }}</h3>
<p>{{ selectedBrand.description || "--" }}</p>
</div>
<div class="brands-view__brand-summary-actions">
<a-button @click="openBrandModal(selectedBrand)">{{ t("brands.editBrand") }}</a-button>
<a-button @click="activeTab = 'competitors'">{{ t("brands.tabs.competitors") }}</a-button>
</div>
</section>
<template v-if="selectedBrandId">
<a-tabs v-model:activeKey="activeTab" class="brands-view__tabs">
<a-tab-pane key="keywords" :tab="t('brands.tabs.keywords')">
<section class="brands-view__keyword-layout">
@@ -660,6 +647,9 @@ async function submitCompetitor(): Promise<void> {
<a-form-item :label="t('brands.form.brandName')">
<a-input v-model:value="brandForm.name" />
</a-form-item>
<a-form-item :label="t('brands.form.brandWebsite')">
<a-input v-model:value="brandForm.website" />
</a-form-item>
<a-form-item :label="t('brands.form.brandDescription')">
<a-textarea v-model:value="brandForm.description" :rows="4" />
</a-form-item>
@@ -745,15 +735,47 @@ async function submitCompetitor(): Promise<void> {
gap: 22px;
}
.brands-view__top-card,
.brands-view__brand-rail,
.brands-view__brand-summary,
.brands-view__keywords-panel,
.brands-view__questions-panel,
.brands-view__competitors-panel {
padding: 24px;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 24px;
border-radius: 12px;
overflow: hidden;
}
.brands-view__top-card {
padding: 0;
}
.brands-view__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
}
.brands-view__header-title h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.brands-view__header-title p {
margin: 6px 0 0 0;
font-size: 13px;
color: #8c8c8c;
}
.brands-view__header-actions {
display: flex;
gap: 12px;
margin-left: auto; /* explicit push to right just in case */
}
.brands-view__section-head {
@@ -764,13 +786,31 @@ async function submitCompetitor(): Promise<void> {
margin-bottom: 18px;
}
.brands-view__section-head--compact h3 {
font-size: 20px;
}
.brands-view__section-head h3 {
margin: 6px 0 0;
font-size: 24px;
font-size: 20px;
font-weight: 800;
color: #111827;
display: flex;
align-items: center;
}
.brands-view__section-head h3::before {
content: "";
display: inline-block;
width: 4px;
height: 18px;
border-radius: 2px;
background: #111827;
margin-right: 12px;
}
.brands-view__section-head--compact h3 {
font-size: 18px;
}
.brands-view__section-head--compact h3::before {
height: 16px;
}
.brands-view__brand-grid {
@@ -784,14 +824,37 @@ async function submitCompetitor(): Promise<void> {
flex-direction: column;
gap: 14px;
padding: 18px;
background: #f8fafc;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 20px;
border-radius: 12px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.brands-view__brand-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0,0,0,0.06);
}
.brands-view__brand-card-actions,
.brands-view__keyword-actions,
.brands-view__competitor-head .brands-view__inline-actions {
opacity: 0;
transition: opacity 0.2s ease;
}
.brands-view__brand-card:hover .brands-view__brand-card-actions,
.brands-view__brand-card:focus-within .brands-view__brand-card-actions,
.brands-view__keyword-item:hover .brands-view__keyword-actions,
.brands-view__keyword-item:focus-within .brands-view__keyword-actions,
.brands-view__competitor-card:hover .brands-view__competitor-head .brands-view__inline-actions,
.brands-view__competitor-card:focus-within .brands-view__competitor-head .brands-view__inline-actions {
opacity: 1;
}
.brands-view__brand-card--active {
border-color: #bfd7ff;
background: #f0f7ff;
box-shadow: 0 18px 40px rgba(31, 92, 255, 0.08);
}
@@ -802,13 +865,11 @@ async function submitCompetitor(): Promise<void> {
}
.brands-view__brand-card h4,
.brands-view__brand-summary h3,
.brands-view__competitor-card h4 {
margin: 0;
}
.brands-view__brand-card p,
.brands-view__brand-summary p,
.brands-view__competitor-card p {
margin: 8px 0 0;
color: var(--muted);
@@ -816,19 +877,12 @@ async function submitCompetitor(): Promise<void> {
}
.brands-view__brand-card-actions,
.brands-view__inline-actions,
.brands-view__keyword-actions,
.brands-view__brand-summary-actions {
.brands-view__inline-actions {
display: flex;
gap: 8px;
}
.brands-view__brand-summary {
display: flex;
justify-content: space-between;
gap: 24px;
}
.brands-view__keyword-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
@@ -847,11 +901,17 @@ async function submitCompetitor(): Promise<void> {
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
background: #f8fafc;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 18px;
border-radius: 12px;
cursor: pointer;
text-align: left;
transition: all 0.2s;
}
.brands-view__keyword-item:hover {
border-color: #bfd7ff;
background: #f0f7ff;
}
.brands-view__keyword-item--active {
@@ -878,9 +938,15 @@ async function submitCompetitor(): Promise<void> {
.brands-view__competitor-card {
padding: 18px;
background: #f8fafc;
background: #fff;
border: 1px solid #e6edf5;
border-radius: 20px;
border-radius: 12px;
transition: transform 0.2s, box-shadow 0.2s;
}
.brands-view__competitor-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(0,0,0,0.06);
}
.brands-view__competitor-head {
@@ -928,7 +994,6 @@ async function submitCompetitor(): Promise<void> {
}
@media (max-width: 720px) {
.brands-view__brand-summary,
.brands-view__section-head {
flex-direction: column;
align-items: stretch;
+10 -163
View File
@@ -14,9 +14,8 @@ import type {
TemplateAssistCompetitor,
TemplateAnalyzeResult,
TemplateOutlineNode,
TemplateSchemaField,
} from "@geo/shared-types";
import { computed, reactive, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
@@ -154,7 +153,6 @@ let outlineNodeSeed = 0;
const currentStep = ref(0);
const articleLocale = ref("zh-CN");
const fieldValues = reactive<Record<string, JsonValue>>({});
const currentArticleId = ref<number | null>(null);
const restoringDraft = ref(false);
const savingDraft = ref(false);
@@ -259,7 +257,6 @@ const templateCardConfig = computed<TemplateCardConfig>(() =>
);
const wizardConfig = computed<TemplateWizardConfig | null>(() => templateCardConfig.value.wizard ?? null);
const isResearchReportTemplate = computed(() => templateDetail.value?.template_key === "research_report");
const wizardManagedFields = computed(() => wizardConfig.value?.managed_fields ?? []);
const templateMeta = computed(() => {
const fallback = getTemplateMeta(templateDetail.value?.template_key ?? "unknown");
return {
@@ -272,20 +269,6 @@ const selectedBrand = computed(() =>
brandsQuery.data.value?.find((brand) => brand.id === selectedBrandId.value) ?? null,
);
const schemaFields = computed<TemplateSchemaField[]>(() => {
return templateDetailQuery.data.value?.schema_json?.fields ?? [];
});
const visibleSchemaFields = computed(() =>
schemaFields.value.filter((field) =>
!isWizardManagedField(
field.name,
templateDetail.value?.template_key,
wizardManagedFields.value,
),
),
);
const keywordOptions = computed(() =>
(keywordsQuery.data.value ?? []).map((item) => ({
label: item.name,
@@ -378,17 +361,7 @@ const structureStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?
const generateStepCopy = computed<TemplateStepConfig>(() => wizardConfig.value?.steps?.generate ?? {});
const brandCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.brand ?? {});
const keywordCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.keywords ?? {});
const fieldsCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.template_fields ?? {});
const competitorsCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.basic?.cards?.competitors ?? {});
const hasTemplateFieldsCardConfig = computed(
() => !isResearchReportTemplate.value && Boolean(wizardConfig.value?.basic?.cards?.template_fields),
);
const editableSchemaFields = computed<TemplateSchemaField[]>(() =>
hasTemplateFieldsCardConfig.value ? visibleSchemaFields.value : [],
);
const showTemplateFieldsCard = computed(
() => editableSchemaFields.value.length > 0 && fieldsCardCopy.value.visible !== false,
);
const titleCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.choose_title ?? {});
const outlineCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.outline ?? {});
const previewCardCopy = computed<TemplateCardCopy>(() => wizardConfig.value?.structure?.cards?.preview ?? {});
@@ -436,7 +409,7 @@ const assistHeading = computed(() =>
: t("templates.wizard.assist.outlineTitle"),
);
function resetWizardState(fields: TemplateSchemaField[], detail: NonNullable<typeof templateDetail.value>): void {
function resetWizardState(detail: NonNullable<typeof templateDetail.value>): void {
currentArticleId.value = draftArticleId.value;
currentStep.value = 0;
articleLocale.value = "zh-CN";
@@ -455,20 +428,6 @@ function resetWizardState(fields: TemplateSchemaField[], detail: NonNullable<typ
customOutlineInput.value = "";
generatedOutline.value = [];
for (const key of Object.keys(fieldValues)) {
delete fieldValues[key];
}
for (const field of fields) {
if (field.default !== undefined) {
fieldValues[field.name] = field.default;
} else if (field.type === "number") {
fieldValues[field.name] = null;
} else {
fieldValues[field.name] = "";
}
}
const defaults = buildOutlineOptions(
templateCardConfig.value.wizard?.outline,
detail.template_key,
@@ -527,26 +486,17 @@ function applyDraftArticleState(detail: ArticleDetail): void {
outlineSections.value = stringListFromUnknown(draftState.outline_sections) ?? outlineSections.value;
generatedOutline.value = decorateOutlineNodes(parseOutlineNodes(draftState.generated_outline));
const savedFields = asRecord(draftState.field_values);
if (savedFields) {
for (const field of schemaFields.value) {
if (Object.prototype.hasOwnProperty.call(savedFields, field.name)) {
fieldValues[field.name] = savedFields[field.name] as JsonValue;
}
}
}
restoringDraft.value = false;
}
watch(
[enabled, schemaFields, templateDetail, () => draftArticleQuery.data.value],
([isOpen, fields, detail, draftArticle]) => {
[enabled, templateDetail, () => draftArticleQuery.data.value],
([isOpen, detail, draftArticle]) => {
if (!isOpen || !detail) {
return;
}
resetWizardState(fields, detail);
resetWizardState(detail);
if (draftArticle && (draftArticle.generate_status === "draft" || draftArticle.generate_status === "failed")) {
applyDraftArticleState(draftArticle);
}
@@ -599,6 +549,9 @@ watch(selectedBrandId, async (brandId) => {
if (brand && !brandName.value.trim()) {
brandName.value = brand.name;
}
if (brand?.website && !officialWebsite.value.trim()) {
officialWebsite.value = brand.website;
}
if (brand?.description && !brandSummary.value.trim()) {
brandSummary.value = brand.description;
}
@@ -633,37 +586,12 @@ function nextOutlineNodeKey(): string {
return `outline-${outlineNodeSeed}`;
}
function isWizardManagedField(name: string, templateKey?: string, managedFields: string[] = []): boolean {
if (managedFields.includes(name)) {
return true;
}
if (name === "count" || name === "brand" || name === "keyword") {
return true;
}
if (templateKey === "top_x_article" && name === "topic") {
return true;
}
return false;
}
function renderFieldPlaceholder(field: TemplateSchemaField): string {
return field.placeholder || `${t("common.inputPlease")}${field.label}`;
}
function validateBasicInfo(): boolean {
if (!normalizedBrandName.value) {
message.warning(t("templates.wizard.messages.missingBrand"));
return false;
}
for (const field of editableSchemaFields.value) {
const value = fieldValues[field.name];
if (field.required && (value === null || value === "" || value === undefined)) {
message.warning(t("templates.wizard.messages.requiredField", { field: field.label }));
return false;
}
}
return true;
}
@@ -992,6 +920,7 @@ async function ensureBrandForLibrary(): Promise<number> {
const created = await brandsApi.create({
name: normalizedBrandName.value,
website: officialWebsite.value.trim() || null,
description: brandSummary.value.trim() || null,
});
selectedBrandId.value = created.id;
@@ -1015,13 +944,6 @@ function buildCompetitorPayload(): TemplateAssistCompetitor[] {
function buildAssistInputParams(): Record<string, JsonValue> {
const payload: Record<string, JsonValue> = {};
for (const field of schemaFields.value) {
const value = fieldValues[field.name];
if (value !== null && value !== "" && value !== undefined) {
payload[field.name] = value;
}
}
const derivedInputs = wizardConfig.value?.derived_inputs ?? {};
for (const [key, config] of Object.entries(derivedInputs)) {
const value = resolveDerivedInputValue(key, config);
@@ -1052,7 +974,6 @@ function buildPayload(): Record<string, JsonValue> {
}))
: undefined;
const payload: Record<string, JsonValue | undefined> = {
...fieldValues,
locale: articleLocale.value,
title: finalTitle.value || undefined,
outline_sections: selectedOutlineLabels.value,
@@ -1080,20 +1001,6 @@ function buildPayload(): Record<string, JsonValue> {
payload[key] = derivedValue;
}
}
} else {
const competitorCount = competitorPayload.length;
if (hasSchemaField("brand")) {
payload.brand = normalizedBrandName.value;
}
if (hasSchemaField("keyword") && primaryKeyword.value) {
payload.keyword = primaryKeyword.value;
}
if (hasSchemaField("topic") && !stringValue(fieldValues.topic)) {
payload.topic = primaryKeyword.value || normalizedBrandName.value;
}
if (hasSchemaField("count")) {
payload.count = competitorCount > 0 ? competitorCount : defaultFieldNumber("count", 5);
}
}
return Object.fromEntries(
@@ -1105,7 +1012,6 @@ function buildDraftState(): Record<string, JsonValue> {
return {
current_step: currentStep.value,
locale: articleLocale.value,
field_values: { ...fieldValues },
selected_brand_id: selectedBrandId.value,
brand_name: brandName.value.trim(),
official_website: officialWebsite.value.trim(),
@@ -1138,23 +1044,6 @@ function buildDraftState(): Record<string, JsonValue> {
};
}
function hasSchemaField(name: string): boolean {
return schemaFields.value.some((field) => field.name === name);
}
function defaultFieldNumber(name: string, fallback: number): number {
const field = schemaFields.value.find((item) => item.name === name);
if (!field) {
return fallback;
}
const numeric = Number(field.default);
return Number.isFinite(numeric) && numeric > 0 ? numeric : fallback;
}
function stringValue(value: JsonValue | undefined): string {
return typeof value === "string" ? value.trim() : "";
}
function handleGenerate(): void {
if (!validateBasicInfo() || !validateStructure() || !validateGeneratedOutline()) {
return;
@@ -1814,7 +1703,7 @@ function resolveDerivedInputValue(
if (typeof config.fallback_number === "number") {
return config.fallback_number;
}
return hasSchemaField(fieldName) ? defaultFieldNumber(fieldName, 0) || undefined : undefined;
return undefined;
}
default:
return undefined;
@@ -2207,48 +2096,6 @@ function onStructureDragEnd(): void {
/>
</div>
<div v-if="showTemplateFieldsCard" class="wizard-card">
<div class="wizard-card__header">
<div>
<h3>{{ fieldsCardCopy.title || t("templates.wizard.sections.templateFields") }}</h3>
<p>{{ fieldsCardCopy.hint || t("templates.wizard.hints.templateFields") }}</p>
</div>
</div>
<div class="field-grid">
<div v-for="field in editableSchemaFields" :key="field.name" class="field-item">
<label :class="{ 'required-asterisk': field.required }">{{ field.label }}</label>
<a-input
v-if="field.type === 'text'"
v-model:value="fieldValues[field.name]"
:placeholder="renderFieldPlaceholder(field)"
/>
<a-input-number
v-else-if="field.type === 'number'"
v-model:value="fieldValues[field.name]"
:placeholder="renderFieldPlaceholder(field)"
style="width: 100%"
:min="1"
/>
<a-select
v-else-if="field.type === 'select'"
v-model:value="fieldValues[field.name]"
:options="(field.options || []).map((option) =>
typeof option === 'object'
? option
: { label: String(option), value: option }
)"
:placeholder="renderFieldPlaceholder(field)"
/>
<a-input
v-else
v-model:value="fieldValues[field.name]"
:placeholder="renderFieldPlaceholder(field)"
/>
</div>
</div>
</div>
<div v-if="showCompetitorsCard" class="wizard-card">
<div class="wizard-card__header">
<div>
+2 -17
View File
@@ -86,29 +86,12 @@ export interface TemplateCard {
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
export interface TemplateSchemaField {
name: string;
type: string;
label: string;
required?: boolean;
default?: JsonValue;
options?: Array<string | number | { label: string; value: string | number }>;
placeholder?: string;
help_text?: string;
}
export interface TemplateSchema {
fields?: TemplateSchemaField[];
[key: string]: JsonValue | TemplateSchemaField[] | undefined;
}
export interface TemplateListItem {
id: number;
scope: string;
origin_type: string;
template_key: string;
template_name: string;
schema_json: TemplateSchema;
prompt_visibility: string;
card_config: Record<string, JsonValue> | null;
status: string;
@@ -298,12 +281,14 @@ export interface ArticleVersion {
export interface BrandRequest {
name: string;
website?: string | null;
description?: string | null;
}
export interface Brand {
id: number;
name: string;
website: string | null;
description: string | null;
status: string;
created_at: string;
+7 -12
View File
@@ -140,12 +140,11 @@ func main() {
// Platform templates
templates := []struct {
key, name, schema, prompt, cardConfig string
key, name, prompt, cardConfig string
}{
{
"top_x_article",
"Top X 文章",
`{"fields":[{"name":"topic","type":"text","label":"话题","required":true},{"name":"count","type":"number","label":"数量","required":true,"default":10}]}`,
`你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。
文章目标:
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
@@ -245,7 +244,6 @@ func main() {
{
"product_review",
"产品评测文章",
`{"fields":[{"name":"product_name","type":"text","label":"产品名称","required":true},{"name":"category","type":"text","label":"品类","required":true}]}`,
`你是一名资深产品评测编辑,请围绕「{{product_name}}」这款{{category}}产品撰写一篇完整的 Markdown 评测文章。
文章目标:
1. 帮读者判断这款产品是否值得关注或购买。
@@ -346,7 +344,6 @@ func main() {
{
"research_report",
"研究报告文章",
`{"fields":[{"name":"subject","type":"text","label":"研究主题","required":true},{"name":"depth","type":"select","label":"深度","options":["overview","detailed"],"default":"overview"}]}`,
`你是一名研究分析师,请围绕「{{subject}}」撰写一篇完整的 Markdown 研究报告。
文章目标:
1. 提炼关键事实、趋势与结构化发现。
@@ -439,7 +436,6 @@ func main() {
{
"brand_search_expansion",
"品牌词搜索扩写",
`{"fields":[{"name":"brand","type":"text","label":"品牌名","required":true},{"name":"keyword","type":"text","label":"关键词","required":true}]}`,
`你是一名擅长搜索意图写作的内容编辑,请围绕「{{brand}} {{keyword}}」撰写一篇完整的 Markdown 品牌词搜索扩写文章。
文章目标:
1. 直接回答搜索用户最关心的问题。
@@ -536,10 +532,10 @@ func main() {
for _, t := range templates {
_, err = tx.Exec(ctx, `
INSERT INTO article_templates (scope, origin_type, template_key, template_name, schema_json, prompt_template, prompt_visibility, card_config_json, status)
VALUES ('platform', 'platform', $1, $2, $3::jsonb, $4, 'visible', $5::jsonb, 'active')
INSERT INTO article_templates (scope, origin_type, template_key, template_name, prompt_template, prompt_visibility, card_config_json, status)
VALUES ('platform', 'platform', $1, $2, $3, 'visible', $4::jsonb, 'active')
ON CONFLICT DO NOTHING
`, t.key, t.name, t.schema, t.prompt, t.cardConfig)
`, t.key, t.name, t.prompt, t.cardConfig)
if err != nil {
log.Fatalf("insert template %s: %v", t.key, err)
}
@@ -547,16 +543,15 @@ func main() {
_, err = tx.Exec(ctx, `
UPDATE article_templates
SET template_name = $2,
schema_json = $3::jsonb,
prompt_template = $4,
card_config_json = $5::jsonb,
prompt_template = $3,
card_config_json = $4::jsonb,
prompt_visibility = 'visible',
status = 'active',
updated_at = NOW()
WHERE scope = 'platform'
AND template_key = $1
AND deleted_at IS NULL
`, t.key, t.name, t.schema, t.prompt, t.cardConfig)
`, t.key, t.name, t.prompt, t.cardConfig)
if err != nil {
log.Fatalf("update template %s: %v", t.key, err)
}
+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 {
@@ -0,0 +1,2 @@
ALTER TABLE article_templates
ADD COLUMN IF NOT EXISTS schema_json JSONB NOT NULL DEFAULT '{}'::jsonb;
@@ -0,0 +1,2 @@
ALTER TABLE article_templates
DROP COLUMN IF EXISTS schema_json;
@@ -0,0 +1,2 @@
ALTER TABLE brands
DROP COLUMN IF EXISTS website;
@@ -0,0 +1,2 @@
ALTER TABLE brands
ADD COLUMN website TEXT;