feat: Enhance article generation and outline handling
- Added support for external markdown synchronization in ArticleEditorCanvas.vue. - Implemented a new function to sync markdown from props, ensuring the editor reflects external changes. - Introduced a removeOutlineSection function in TemplateWizardView.vue to manage outline sections dynamically. - Updated UI components to improve user experience, including replacing a-input with a-textarea for better text handling. - Refactored CSS styles for a cleaner layout and improved responsiveness in TemplateWizardView.vue. - Enhanced LLM generation requests to include response format options in ark.go and client.go. - Updated template assist logic to enforce structured outline outputs in template_assist.go. - Added tests for outline result decoding and prompt generation to ensure robustness. - Created migration scripts to update prompt templates with new writing requirements for top X articles.
This commit is contained in:
@@ -20,7 +20,7 @@ import {
|
|||||||
wrapInHeadingCommand,
|
wrapInHeadingCommand,
|
||||||
wrapInOrderedListCommand,
|
wrapInOrderedListCommand,
|
||||||
} from "@milkdown/kit/preset/commonmark";
|
} from "@milkdown/kit/preset/commonmark";
|
||||||
import { callCommand } from "@milkdown/kit/utils";
|
import { callCommand, replaceAll } from "@milkdown/kit/utils";
|
||||||
import { Crepe, CrepeFeature } from "@milkdown/crepe";
|
import { Crepe, CrepeFeature } from "@milkdown/crepe";
|
||||||
import "@milkdown/crepe/theme/common/style.css";
|
import "@milkdown/crepe/theme/common/style.css";
|
||||||
import "@milkdown/crepe/theme/frame.css";
|
import "@milkdown/crepe/theme/frame.css";
|
||||||
@@ -41,6 +41,7 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const crepeRef = ref<Crepe | null>(null);
|
const crepeRef = ref<Crepe | null>(null);
|
||||||
|
const syncingExternalMarkdown = ref(false);
|
||||||
|
|
||||||
const { loading } = useEditor((root) => {
|
const { loading } = useEditor((root) => {
|
||||||
const crepe = new Crepe({
|
const crepe = new Crepe({
|
||||||
@@ -51,12 +52,17 @@ const { loading } = useEditor((root) => {
|
|||||||
[CrepeFeature.Toolbar]: true,
|
[CrepeFeature.Toolbar]: true,
|
||||||
[CrepeFeature.Latex]: false,
|
[CrepeFeature.Latex]: false,
|
||||||
[CrepeFeature.BlockEdit]: false,
|
[CrepeFeature.BlockEdit]: false,
|
||||||
|
[CrepeFeature.Placeholder]: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
crepe.setReadonly(!!props.disabled);
|
crepe.setReadonly(!!props.disabled);
|
||||||
crepe.on((listener) => {
|
crepe.on((listener) => {
|
||||||
listener.markdownUpdated((_ctx, markdown) => {
|
listener.markdownUpdated((_ctx, markdown) => {
|
||||||
|
if (syncingExternalMarkdown.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
emit("update:modelValue", markdown);
|
emit("update:modelValue", markdown);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -68,6 +74,25 @@ const { loading } = useEditor((root) => {
|
|||||||
const [instanceLoading, getEditor] = useInstance();
|
const [instanceLoading, getEditor] = useInstance();
|
||||||
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
|
||||||
|
|
||||||
|
function syncMarkdownFromProps(nextValue: string): void {
|
||||||
|
const editor = getEditor();
|
||||||
|
const crepe = crepeRef.value;
|
||||||
|
if (!editor || !crepe) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedValue = nextValue || "";
|
||||||
|
if (crepe.getMarkdown() === resolvedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncingExternalMarkdown.value = true;
|
||||||
|
editor.action(replaceAll(resolvedValue, true));
|
||||||
|
queueMicrotask(() => {
|
||||||
|
syncingExternalMarkdown.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.disabled,
|
() => props.disabled,
|
||||||
(value) => {
|
(value) => {
|
||||||
@@ -76,6 +101,18 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.modelValue, loading, instanceLoading],
|
||||||
|
([value, editorLoading, currentInstanceLoading]) => {
|
||||||
|
if (editorLoading || currentInstanceLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMarkdownFromProps(value);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
crepeRef.value = null;
|
crepeRef.value = null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1276,6 +1276,18 @@ function addCustomOutlineSection(): void {
|
|||||||
customOutlineInput.value = "";
|
customOutlineInput.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeOutlineSection(key: string): void {
|
||||||
|
outlineSections.value = outlineSections.value.filter((item) => item !== key);
|
||||||
|
|
||||||
|
const target = outlineOptions.value.find((item) => item.key === key);
|
||||||
|
if (!target?.custom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
customOutlineSections.value = customOutlineSections.value.filter((item) => item.key !== key);
|
||||||
|
outlineOptionOrder.value = outlineOptionOrder.value.filter((item) => item !== key);
|
||||||
|
}
|
||||||
|
|
||||||
function sortOutlineOptions(
|
function sortOutlineOptions(
|
||||||
options: ResolvedOutlineOption[],
|
options: ResolvedOutlineOption[],
|
||||||
order: string[],
|
order: string[],
|
||||||
@@ -2035,12 +2047,6 @@ function moveArrayItemBefore<T>(items: T[], sourceIndex: number, targetIndex: nu
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
function outlineInputWidth(value: string): string {
|
|
||||||
const length = Array.from(value.trim()).length;
|
|
||||||
const widthEm = Math.min(Math.max(length + 4, 14), 42);
|
|
||||||
return `${widthEm}em`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStructureDragStart(event: DragEvent, key: string): void {
|
function onStructureDragStart(event: DragEvent, key: string): void {
|
||||||
if (!event.dataTransfer) {
|
if (!event.dataTransfer) {
|
||||||
return;
|
return;
|
||||||
@@ -2388,9 +2394,20 @@ function onStructureDragEnd(): void {
|
|||||||
>
|
>
|
||||||
{{ section.label }}
|
{{ section.label }}
|
||||||
</a-checkbox>
|
</a-checkbox>
|
||||||
<span class="outline-option__handle">
|
<div class="outline-option__actions">
|
||||||
<HolderOutlined />
|
<a-button
|
||||||
</span>
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
:title="t('common.delete')"
|
||||||
|
@click.stop="removeOutlineSection(section.key)"
|
||||||
|
>
|
||||||
|
<template #icon><DeleteOutlined /></template>
|
||||||
|
</a-button>
|
||||||
|
<span class="outline-option__handle">
|
||||||
|
<HolderOutlined />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2476,10 +2493,10 @@ function onStructureDragEnd(): void {
|
|||||||
@drop="onOutlineDrop($event, section.id, 'branch', null)"
|
@drop="onOutlineDrop($event, section.id, 'branch', null)"
|
||||||
>
|
>
|
||||||
<span class="outline-branch__dot" />
|
<span class="outline-branch__dot" />
|
||||||
<a-input
|
<a-textarea
|
||||||
v-model:value="section.outline"
|
v-model:value="section.outline"
|
||||||
class="outline-input"
|
class="outline-input"
|
||||||
:style="{ '--outline-input-width': outlineInputWidth(section.outline) }"
|
:auto-size="{ minRows: 1 }"
|
||||||
:placeholder="t('templates.wizard.placeholders.outlineNode')"
|
:placeholder="t('templates.wizard.placeholders.outlineNode')"
|
||||||
/>
|
/>
|
||||||
<div class="outline-node-actions">
|
<div class="outline-node-actions">
|
||||||
@@ -2511,10 +2528,10 @@ function onStructureDragEnd(): void {
|
|||||||
@drop="onOutlineDrop($event, child.id, 'leaf', section.id)"
|
@drop="onOutlineDrop($event, child.id, 'leaf', section.id)"
|
||||||
>
|
>
|
||||||
<span class="outline-leaf__dot" />
|
<span class="outline-leaf__dot" />
|
||||||
<a-input
|
<a-textarea
|
||||||
v-model:value="child.outline"
|
v-model:value="child.outline"
|
||||||
class="outline-input"
|
class="outline-input"
|
||||||
:style="{ '--outline-input-width': outlineInputWidth(child.outline) }"
|
:auto-size="{ minRows: 1 }"
|
||||||
:placeholder="t('templates.wizard.placeholders.outlineNode')"
|
:placeholder="t('templates.wizard.placeholders.outlineNode')"
|
||||||
/>
|
/>
|
||||||
<div class="outline-node-actions">
|
<div class="outline-node-actions">
|
||||||
@@ -2657,9 +2674,7 @@ function onStructureDragEnd(): void {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: calc(100vh - 64px);
|
min-height: calc(100vh - 64px);
|
||||||
background:
|
background: #fff;
|
||||||
radial-gradient(circle at top left, rgba(75, 132, 255, 0.08), transparent 28%),
|
|
||||||
linear-gradient(180deg, #f5f8ff 0%, #f7f9fc 100%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-page__loading {
|
.wizard-page__loading {
|
||||||
@@ -2671,10 +2686,9 @@ function onStructureDragEnd(): void {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 24px;
|
padding: 16px 32px;
|
||||||
border-bottom: 1px solid rgba(22, 119, 255, 0.08);
|
border-bottom: 1px solid #edf2f7;
|
||||||
backdrop-filter: blur(14px);
|
background: #fff;
|
||||||
background: rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@@ -2691,9 +2705,9 @@ function onStructureDragEnd(): void {
|
|||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #10203d;
|
color: #111827;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-accent {
|
.header-accent {
|
||||||
@@ -2712,27 +2726,26 @@ function onStructureDragEnd(): void {
|
|||||||
.wizard-page__main {
|
.wizard-page__main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
margin: 0 auto;
|
||||||
margin: 24px;
|
width: 100%;
|
||||||
|
max-width: 1160px;
|
||||||
|
padding: 24px 32px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-steps-container {
|
.wizard-steps-container {
|
||||||
padding: 20px 24px;
|
padding: 0 0 24px;
|
||||||
border: 1px solid rgba(20, 44, 88, 0.08);
|
border-bottom: 1px solid #edf2f7;
|
||||||
border-radius: 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.86);
|
|
||||||
box-shadow: 0 18px 48px rgba(17, 39, 81, 0.06);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-section {
|
.wizard-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-section--structure {
|
.wizard-section--structure {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 600px;
|
grid-template-columns: minmax(0, 1fr) 460px;
|
||||||
|
gap: 48px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2744,11 +2757,13 @@ function onStructureDragEnd(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wizard-card {
|
.wizard-card {
|
||||||
padding: 24px;
|
padding: 32px 0;
|
||||||
border: 1px solid rgba(20, 44, 88, 0.08);
|
border-bottom: 1px solid #edf2f7;
|
||||||
border-radius: 28px;
|
}
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(247, 250, 255, 0.96) 100%);
|
|
||||||
box-shadow: 0 18px 48px rgba(17, 39, 81, 0.06);
|
.wizard-section > .wizard-card:last-child,
|
||||||
|
.wizard-structure-main > .wizard-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-card__header {
|
.wizard-card__header {
|
||||||
@@ -2756,20 +2771,33 @@ function onStructureDragEnd(): void {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-card__header h3 {
|
.wizard-card__header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #12213f;
|
color: #111827;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-card__header h3::before {
|
||||||
|
content: "";
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: #111827;
|
||||||
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-card__header p {
|
.wizard-card__header p {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0 16px;
|
||||||
color: #71809a;
|
color: #667085;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-grid {
|
.field-grid {
|
||||||
@@ -2789,9 +2817,16 @@ function onStructureDragEnd(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field-item > label {
|
.field-item > label {
|
||||||
color: #22304d;
|
display: inline-flex;
|
||||||
font-size: 14px;
|
align-items: center;
|
||||||
font-weight: 600;
|
margin-bottom: 12px;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item > label::after {
|
||||||
|
content: ":";
|
||||||
}
|
}
|
||||||
|
|
||||||
.required-asterisk::before {
|
.required-asterisk::before {
|
||||||
@@ -3059,6 +3094,12 @@ function onStructureDragEnd(): void {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outline-option__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.custom-outline-row {
|
.custom-outline-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
@@ -3153,14 +3194,14 @@ function onStructureDragEnd(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.outline-input {
|
.outline-input {
|
||||||
width: min(100%, var(--outline-input-width, 18em));
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-shrink: 1;
|
|
||||||
border-color: transparent !important;
|
border-color: transparent !important;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.outline-input:hover {
|
.outline-input:hover {
|
||||||
@@ -3185,7 +3226,8 @@ function onStructureDragEnd(): void {
|
|||||||
|
|
||||||
.preview-card {
|
.preview-card {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 96px;
|
top: 32px;
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-shell {
|
.preview-shell {
|
||||||
|
|||||||
@@ -154,8 +154,10 @@ func main() {
|
|||||||
写作要求:
|
写作要求:
|
||||||
- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。
|
- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。
|
||||||
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
|
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
|
||||||
|
- 如果提供了 brand_name,且文章属于品牌、公司或服务商对比型推荐内容,应将该品牌作为主推对象:默认排在第 1 位、正文篇幅明显多于其他对象、结论优先推荐。
|
||||||
|
- 对 brand_name 对应对象重点展开核心优势、本地服务、适合人群、报价或配置亮点、交付安装、售后与避坑建议;其他对象保持客观但更简洁的差异化介绍。
|
||||||
- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。
|
- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。
|
||||||
- 引言先交代推荐标准与读者能得到什么,结尾给出简洁明确的选择建议。`,
|
- 引言先交代推荐标准与读者能得到什么,结尾优先总结 brand_name 为什么值得优先考虑,再补充其他对象各自适合的预算与需求。`,
|
||||||
`{
|
`{
|
||||||
"hero": {
|
"hero": {
|
||||||
"accent": "Top X",
|
"accent": "Top X",
|
||||||
|
|||||||
@@ -132,6 +132,32 @@ 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}
|
||||||
|
}
|
||||||
|
|
||||||
var tools []*responses.ResponsesTool
|
var tools []*responses.ResponsesTool
|
||||||
if req.WebSearch != nil && req.WebSearch.Enabled {
|
if req.WebSearch != nil && req.WebSearch.Enabled {
|
||||||
webSearchLimit := c.webSearchLimit
|
webSearchLimit := c.webSearchLimit
|
||||||
@@ -159,6 +185,7 @@ func (c *arkClient) Generate(ctx context.Context, req GenerateRequest, onDelta f
|
|||||||
Temperature: &c.temperature,
|
Temperature: &c.temperature,
|
||||||
TopP: c.topP,
|
TopP: c.topP,
|
||||||
Tools: tools,
|
Tools: tools,
|
||||||
|
Text: textFormat,
|
||||||
Reasoning: c.reasoning,
|
Reasoning: c.reasoning,
|
||||||
Input: &responses.ResponsesInput{
|
Input: &responses.ResponsesInput{
|
||||||
Union: &responses.ResponsesInput_ListValue{
|
Union: &responses.ResponsesInput_ListValue{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type GenerateRequest struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
MaxOutputTokens int64
|
MaxOutputTokens int64
|
||||||
WebSearch *WebSearchOptions
|
WebSearch *WebSearchOptions
|
||||||
|
ResponseFormat *ResponseFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerateResult struct {
|
type GenerateResult struct {
|
||||||
@@ -24,6 +25,22 @@ type GenerateResult struct {
|
|||||||
Model string
|
Model string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ResponseFormat struct {
|
||||||
|
Type ResponseFormatType
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
SchemaJSON []byte
|
||||||
|
Strict bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResponseFormatType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ResponseFormatTypeText ResponseFormatType = "text"
|
||||||
|
ResponseFormatTypeJSONObject ResponseFormatType = "json_object"
|
||||||
|
ResponseFormatTypeJSONSchema ResponseFormatType = "json_schema"
|
||||||
|
)
|
||||||
|
|
||||||
type WebSearchOptions struct {
|
type WebSearchOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Limit int32
|
Limit int32
|
||||||
|
|||||||
@@ -471,6 +471,11 @@ func (s *TemplateService) executeOutlineTask(ctx context.Context, repo repositor
|
|||||||
|
|
||||||
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
result, err := s.llm.Generate(ctx, llm.GenerateRequest{
|
||||||
Prompt: buildOutlinePrompt(job.TemplateKey, job.TemplateName, *job.OutlineRequest, job.OutlinePrompt),
|
Prompt: buildOutlinePrompt(job.TemplateKey, job.TemplateName, *job.OutlineRequest, job.OutlinePrompt),
|
||||||
|
ResponseFormat: &llm.ResponseFormat{
|
||||||
|
Type: llm.ResponseFormatTypeJSONObject,
|
||||||
|
Name: "article_outline",
|
||||||
|
Description: "文章大纲 JSON 对象,必须包含 outline 数组字段。",
|
||||||
|
},
|
||||||
}, nil)
|
}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
|
s.failAssist(ctx, job.TaskID, job.TaskType, job.TenantID, err)
|
||||||
@@ -699,9 +704,9 @@ func buildAnalyzePrompt(templateKey, templateName string, req AnalyzeTaskRequest
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||||
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
|
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||||
- 关键词应为简洁的搜索短语。
|
- 关键词应为简洁的搜索短语。
|
||||||
- 竞品应去重且真实。不确定时 website 可留空,但不要编造虚假 URL。
|
- 竞品应去重且真实。不确定时 website 字段可留空,但不要编造虚假链接。
|
||||||
- brand_summary 限 1-2 句。
|
- brand_summary 限 1-2 句。
|
||||||
- 最多返回 6 个竞品和 5 个关键词。
|
- 最多返回 6 个竞品和 5 个关键词。
|
||||||
|
|
||||||
@@ -750,7 +755,7 @@ func buildTitlePrompt(templateKey, templateName string, req TitleTaskRequest, ti
|
|||||||
规则:
|
规则:
|
||||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||||
- 返回 5 个字符串的 JSON 数组,不要返回对象。
|
- 返回 5 个字符串的 JSON 数组,不要返回对象。
|
||||||
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
|
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||||
- 标题应实用、具体,并契合当前模板的内容方向。
|
- 标题应实用、具体,并契合当前模板的内容方向。
|
||||||
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
|
- 尊重当前关键词列表和竞品上下文,因为用户可能已手动编辑。
|
||||||
- 避免泛泛的广告文案和空洞口号。
|
- 避免泛泛的广告文案和空洞口号。
|
||||||
@@ -765,6 +770,25 @@ JSON 输出示例:
|
|||||||
|
|
||||||
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
|
func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest, outlinePromptTemplate *string) string {
|
||||||
contextPayload := outlinePromptParams(templateKey, templateName, req)
|
contextPayload := outlinePromptParams(templateKey, templateName, req)
|
||||||
|
outputExample := `{
|
||||||
|
"outline": [
|
||||||
|
{
|
||||||
|
"outline": "网站列表",
|
||||||
|
"children": [
|
||||||
|
{ "outline": "竞品 A" },
|
||||||
|
{ "outline": "竞品 B" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"outline": "文章关键要点",
|
||||||
|
"children": [
|
||||||
|
{ "outline": "要点 1" },
|
||||||
|
{ "outline": "要点 2" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`
|
||||||
|
|
||||||
if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" {
|
if outlinePromptTemplate != nil && strings.TrimSpace(*outlinePromptTemplate) != "" {
|
||||||
basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload))
|
basePrompt := strings.TrimSpace(renderPromptTemplate(outlinePromptTemplate, contextPayload))
|
||||||
if basePrompt != "" {
|
if basePrompt != "" {
|
||||||
@@ -775,31 +799,18 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
|
|||||||
sections = append(sections, strings.Join([]string{
|
sections = append(sections, strings.Join([]string{
|
||||||
"输出要求:",
|
"输出要求:",
|
||||||
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
"- 仅返回 JSON,不要用 Markdown 代码块包裹。",
|
||||||
`- 返回大纲节点的 JSON 数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
`- 返回 JSON 对象,格式固定为 {"outline":[...]}`,
|
||||||
|
`- outline 字段是顶层大纲节点数组,每个节点格式为 {"outline":"...","children":[...]}`,
|
||||||
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
|
"- 每个已选大纲段落应作为顶层节点出现,语言与请求保持一致。",
|
||||||
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
|
"- 尊重当前关键词、竞品和关键要点,因为用户可能已手动编辑。",
|
||||||
|
"- 不要返回额外字段,不要返回纯数组,不要返回说明文字。",
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
|
sections = append(sections, "JSON 输出示例:\n"+outputExample)
|
||||||
return strings.Join(sections, "\n\n")
|
return strings.Join(sections, "\n\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
contextJSON, _ := json.MarshalIndent(contextPayload, "", " ")
|
||||||
outputExample := `[
|
|
||||||
{
|
|
||||||
"outline": "网站列表",
|
|
||||||
"children": [
|
|
||||||
{ "outline": "竞品 A" },
|
|
||||||
{ "outline": "竞品 B" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"outline": "文章关键要点",
|
|
||||||
"children": [
|
|
||||||
{ "outline": "要点 1" },
|
|
||||||
{ "outline": "要点 2" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]`
|
|
||||||
|
|
||||||
return strings.TrimSpace(fmt.Sprintf(`
|
return strings.TrimSpace(fmt.Sprintf(`
|
||||||
你是一位专业的 GEO 内容策略师。
|
你是一位专业的 GEO 内容策略师。
|
||||||
@@ -809,17 +820,18 @@ func buildOutlinePrompt(templateKey, templateName string, req OutlineTaskRequest
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
- 仅返回 JSON,不要用 Markdown 代码块包裹。
|
||||||
- 返回顶层大纲节点的 JSON 数组。
|
- 返回 JSON 对象,格式固定为 {"outline":[...]}。
|
||||||
- 每个顶层节点的 "outline" 值必须保持已选段落标签原文。
|
- outline 字段中的每个顶层节点 "outline" 值必须保持已选段落标签原文。
|
||||||
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
|
- 在合适的位置为每个顶层段落添加简洁的子大纲条目。
|
||||||
- 使用与请求 locale 一致的语言。zh-CN 用简体中文,en-US 用英文。
|
- 使用与请求的语言设置一致。zh-CN 用简体中文,en-US 用英文。
|
||||||
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
|
- 尊重当前标题、关键词、竞品、关键要点和品牌上下文,因为用户可能已手动编辑。
|
||||||
- 大纲应具体、不含推广语气,适合后续完整成文。
|
- 大纲应具体、不含推广语气,适合后续完整成文。
|
||||||
|
- 不要返回额外字段,不要返回纯数组,不要返回说明文字。
|
||||||
|
|
||||||
Template context:
|
模板上下文:
|
||||||
%s
|
%s
|
||||||
|
|
||||||
Output JSON example:
|
JSON 输出示例:
|
||||||
%s
|
%s
|
||||||
`, string(contextJSON), outputExample))
|
`, string(contextJSON), outputExample))
|
||||||
}
|
}
|
||||||
@@ -996,29 +1008,45 @@ func decodeTitleResult(raw string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func decodeOutlineResult(raw string) ([]OutlineNode, error) {
|
func decodeOutlineResult(raw string) ([]OutlineNode, error) {
|
||||||
cleaned := stripJSONFence(raw)
|
var lastErr error
|
||||||
|
for _, candidate := range extractJSONCandidates(raw) {
|
||||||
|
var result []OutlineNode
|
||||||
|
if err := json.Unmarshal([]byte(candidate), &result); err == nil {
|
||||||
|
return result, nil
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
|
||||||
var result []OutlineNode
|
var wrapped struct {
|
||||||
if err := json.Unmarshal([]byte(cleaned), &result); err == nil {
|
Result []OutlineNode `json:"result"`
|
||||||
return result, nil
|
Outline []OutlineNode `json:"outline"`
|
||||||
|
Items []OutlineNode `json:"items"`
|
||||||
|
Data []OutlineNode `json:"data"`
|
||||||
|
Sections []OutlineNode `json:"sections"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(candidate), &wrapped); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(wrapped.Result) > 0:
|
||||||
|
return wrapped.Result, nil
|
||||||
|
case len(wrapped.Outline) > 0:
|
||||||
|
return wrapped.Outline, nil
|
||||||
|
case len(wrapped.Items) > 0:
|
||||||
|
return wrapped.Items, nil
|
||||||
|
case len(wrapped.Data) > 0:
|
||||||
|
return wrapped.Data, nil
|
||||||
|
case len(wrapped.Sections) > 0:
|
||||||
|
return wrapped.Sections, nil
|
||||||
|
default:
|
||||||
|
lastErr = fmt.Errorf("outline object does not contain a supported outline field")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if lastErr == nil {
|
||||||
var wrapped struct {
|
lastErr = fmt.Errorf("empty content")
|
||||||
Result []OutlineNode `json:"result"`
|
|
||||||
Outline []OutlineNode `json:"outline"`
|
|
||||||
Items []OutlineNode `json:"items"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(cleaned), &wrapped); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode outline result: %w", err)
|
|
||||||
}
|
|
||||||
switch {
|
|
||||||
case len(wrapped.Result) > 0:
|
|
||||||
return wrapped.Result, nil
|
|
||||||
case len(wrapped.Outline) > 0:
|
|
||||||
return wrapped.Outline, nil
|
|
||||||
default:
|
|
||||||
return wrapped.Items, nil
|
|
||||||
}
|
}
|
||||||
|
return nil, fmt.Errorf("decode outline result: %w", lastErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripJSONFence(raw string) string {
|
func stripJSONFence(raw string) string {
|
||||||
@@ -1029,6 +1057,92 @@ func stripJSONFence(raw string) string {
|
|||||||
return cleaned
|
return cleaned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractJSONCandidates(raw string) []string {
|
||||||
|
cleaned := stripJSONFence(raw)
|
||||||
|
if cleaned == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := make([]string, 0, 8)
|
||||||
|
seen := make(map[string]struct{}, 8)
|
||||||
|
addCandidate := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := seen[value]; exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
candidates = append(candidates, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate(cleaned)
|
||||||
|
for i := 0; i < len(cleaned); i++ {
|
||||||
|
switch cleaned[i] {
|
||||||
|
case '{', '[':
|
||||||
|
if fragment, ok := extractBalancedJSONFragment(cleaned[i:]); ok {
|
||||||
|
addCandidate(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBalancedJSONFragment(input string) (string, bool) {
|
||||||
|
if input == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
var open, close byte
|
||||||
|
switch input[0] {
|
||||||
|
case '{':
|
||||||
|
open = '{'
|
||||||
|
close = '}'
|
||||||
|
case '[':
|
||||||
|
open = '['
|
||||||
|
close = ']'
|
||||||
|
default:
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
depth := 0
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
ch := input[i]
|
||||||
|
if inString {
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch ch {
|
||||||
|
case '\\':
|
||||||
|
escaped = true
|
||||||
|
case '"':
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ch {
|
||||||
|
case '"':
|
||||||
|
inString = true
|
||||||
|
case open:
|
||||||
|
depth += 1
|
||||||
|
case close:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0 {
|
||||||
|
return input[:i+1], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAnalyzeResult(result AnalyzeTaskResult) AnalyzeTaskResult {
|
func normalizeAnalyzeResult(result AnalyzeTaskResult) AnalyzeTaskResult {
|
||||||
result.BrandSummary = strings.TrimSpace(result.BrandSummary)
|
result.BrandSummary = strings.TrimSpace(result.BrandSummary)
|
||||||
result.Keywords = normalizeStringList(result.Keywords, 5)
|
result.Keywords = normalizeStringList(result.Keywords, 5)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecodeOutlineResultAcceptsWrappedObject(t *testing.T) {
|
||||||
|
raw := `{"outline":[{"outline":"网站列表","children":[{"outline":"竞品 A"}]}]}`
|
||||||
|
|
||||||
|
result, err := decodeOutlineResult(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeOutlineResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 1 || result[0].Outline != "网站列表" {
|
||||||
|
t.Fatalf("decodeOutlineResult() = %#v, want outline payload", result)
|
||||||
|
}
|
||||||
|
if len(result[0].Children) != 1 || result[0].Children[0].Outline != "竞品 A" {
|
||||||
|
t.Fatalf("decodeOutlineResult() children = %#v, want child outline", result[0].Children)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeOutlineResultExtractsArrayFromNarrativeText(t *testing.T) {
|
||||||
|
raw := "以下是整理后的结果:\n```json\n[\n {\"outline\":\"文章关键要点\",\"children\":[{\"outline\":\"要点 1\"}]}\n]\n```\n请直接使用。"
|
||||||
|
|
||||||
|
result, err := decodeOutlineResult(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeOutlineResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 1 || result[0].Outline != "文章关键要点" {
|
||||||
|
t.Fatalf("decodeOutlineResult() = %#v, want extracted array payload", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeOutlineResultExtractsInnerArrayFromMalformedWrapper(t *testing.T) {
|
||||||
|
raw := "结果如下:{\n[\n {\"outline\":\"常见问题\"}\n]\n}"
|
||||||
|
|
||||||
|
result, err := decodeOutlineResult(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decodeOutlineResult() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(result) != 1 || result[0].Outline != "常见问题" {
|
||||||
|
t.Fatalf("decodeOutlineResult() = %#v, want inner array payload", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildOutlinePromptForConfiguredTemplateForcesOutlineObject(t *testing.T) {
|
||||||
|
template := "你正在为文章生成大纲。\n标题: {{title}}\n已选段落: {{outline_sections}}"
|
||||||
|
|
||||||
|
prompt := buildOutlinePrompt("recommendation_list", "推荐类模板", OutlineTaskRequest{
|
||||||
|
Locale: "zh-CN",
|
||||||
|
Title: "测试标题",
|
||||||
|
OutlineSections: []string{"引言", "文章关键要点"},
|
||||||
|
}, &template)
|
||||||
|
|
||||||
|
if !strings.Contains(prompt, `{"outline":[...]}`) {
|
||||||
|
t.Fatalf("buildOutlinePrompt() = %q, want fixed outline object instruction", prompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "JSON 输出示例") {
|
||||||
|
t.Fatalf("buildOutlinePrompt() = %q, want JSON example", prompt)
|
||||||
|
}
|
||||||
|
if !strings.Contains(prompt, "不要返回纯数组") {
|
||||||
|
t.Fatalf("buildOutlinePrompt() = %q, want pure-array prohibition", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
|
|
||||||
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
|
var promptVariablePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_]+)\s*\}\}`)
|
||||||
|
|
||||||
func buildGenerationPrompt(templateName string, promptTemplate *string, params map[string]interface{}) string {
|
func buildGenerationPrompt(templateKey, templateName string, promptTemplate *string, params map[string]interface{}) string {
|
||||||
basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params))
|
basePrompt := strings.TrimSpace(renderPromptTemplate(promptTemplate, params))
|
||||||
if basePrompt == "" {
|
if basePrompt == "" {
|
||||||
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
|
basePrompt = fmt.Sprintf("你是一名专业内容编辑,请基于已提供的信息,为模板「%s」写出一篇完整的 Markdown 文章。", templateName)
|
||||||
@@ -40,6 +40,10 @@ func buildGenerationPrompt(templateName string, promptTemplate *string, params m
|
|||||||
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
|
"- 结尾应给出清晰结论、适合对象、不适合对象,或下一步建议,帮助读者完成判断。",
|
||||||
}, "\n"))
|
}, "\n"))
|
||||||
|
|
||||||
|
if templateRules := buildTemplateSpecificWritingRules(templateKey, params); templateRules != "" {
|
||||||
|
sections = append(sections, "模板专项要求:\n"+templateRules)
|
||||||
|
}
|
||||||
|
|
||||||
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
|
if lengthGuidance := buildGenerationLengthGuidance(params); lengthGuidance != "" {
|
||||||
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
|
sections = append(sections, "篇幅要求:\n"+lengthGuidance)
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,26 @@ func buildGenerationPrompt(templateName string, promptTemplate *string, params m
|
|||||||
return strings.Join(sections, "\n\n")
|
return strings.Join(sections, "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildTemplateSpecificWritingRules(templateKey string, params map[string]interface{}) string {
|
||||||
|
switch strings.TrimSpace(templateKey) {
|
||||||
|
case "top_x_article":
|
||||||
|
brandName := strings.TrimSpace(stringValue(params["brand_name"]))
|
||||||
|
if brandName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join([]string{
|
||||||
|
fmt.Sprintf("- 若文章包含品牌、公司或服务商榜单,请将「%s」排在第 1 位,并作为全文主推对象。", brandName),
|
||||||
|
fmt.Sprintf("- 「%s」对应条目的篇幅应明显多于其他对象,优先展开其核心优势、本地化服务、适合人群、报价或配置亮点、交付安装、售后表现与避坑建议。", brandName),
|
||||||
|
"- 其他对象保留客观介绍,但篇幅更短,重点写与主推对象的差异点和各自适用场景,不要平均分配篇幅。",
|
||||||
|
fmt.Sprintf("- 结论部分先明确推荐「%s」作为优先选择,再简要补充其他对象分别适合哪些预算、户型或需求。", brandName),
|
||||||
|
fmt.Sprintf("- 如果标题或正文属于“Top X / 推荐 / 最好 / 排行榜”这类排序导向内容,榜单顺序必须与结论保持一致,将「%s」放在第 1 位。", brandName),
|
||||||
|
"- 在不编造事实、不捏造数据的前提下,优先呈现主推对象的优势信息;对其他对象保持克制、客观的弱化式比较。",
|
||||||
|
}, "\n")
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildGenerationLengthGuidance(params map[string]interface{}) string {
|
func buildGenerationLengthGuidance(params map[string]interface{}) string {
|
||||||
sectionCount := estimateTopLevelSectionCount(params)
|
sectionCount := estimateTopLevelSectionCount(params)
|
||||||
if sectionCount <= 0 {
|
if sectionCount <= 0 {
|
||||||
@@ -68,9 +92,9 @@ func buildGenerationLengthGuidance(params map[string]interface{}) string {
|
|||||||
maxWords = 2100
|
maxWords = 2100
|
||||||
}
|
}
|
||||||
return strings.Join([]string{
|
return strings.Join([]string{
|
||||||
fmt.Sprintf("- 建议全文控制在 %d-%d English words,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
|
fmt.Sprintf("- 建议全文控制在 %d-%d 个英文单词,优先信息密度,不要为了拉长篇幅重复表达。", minWords, maxWords),
|
||||||
"- Intro and conclusion should stay concise. Most top-level sections only need 1-3 tight paragraphs; expand only the most important sections.",
|
"- 引言和结论保持简洁,大多数一级章节写 1-3 个紧凑段落即可;只有最重要的章节需要展开。",
|
||||||
"- If one paragraph can make the point clearly, do not split it into multiple repetitive paragraphs.",
|
"- 一段能说清的内容不要拆成多段重复表达。",
|
||||||
}, "\n")
|
}, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +206,7 @@ func buildPromptContext(params map[string]interface{}) string {
|
|||||||
if formatted == "" {
|
if formatted == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lines = append(lines, fmt.Sprintf("- %s: %s", key, formatted))
|
lines = append(lines, fmt.Sprintf("- %s: %s", promptContextLabel(key), formatted))
|
||||||
used[key] = struct{}{}
|
used[key] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +224,67 @@ func buildPromptContext(params map[string]interface{}) string {
|
|||||||
return strings.Join(lines, "\n")
|
return strings.Join(lines, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func promptContextLabel(key string) string {
|
||||||
|
switch key {
|
||||||
|
case "locale":
|
||||||
|
return "语言"
|
||||||
|
case "title":
|
||||||
|
return "标题"
|
||||||
|
case "topic":
|
||||||
|
return "主题"
|
||||||
|
case "product_name":
|
||||||
|
return "产品名"
|
||||||
|
case "subject":
|
||||||
|
return "研究主题"
|
||||||
|
case "brand_name":
|
||||||
|
return "品牌名"
|
||||||
|
case "brand":
|
||||||
|
return "品牌"
|
||||||
|
case "official_website", "website":
|
||||||
|
return "官网"
|
||||||
|
case "primary_keyword":
|
||||||
|
return "核心关键词"
|
||||||
|
case "keywords", "existing_keywords":
|
||||||
|
return "关键词"
|
||||||
|
case "competitors", "existing_competitors":
|
||||||
|
return "竞品"
|
||||||
|
case "competitor_names":
|
||||||
|
return "竞品名称"
|
||||||
|
case "competitor_count":
|
||||||
|
return "竞品数量"
|
||||||
|
case "brand_summary":
|
||||||
|
return "品牌摘要"
|
||||||
|
case "category":
|
||||||
|
return "品类"
|
||||||
|
case "count":
|
||||||
|
return "数量"
|
||||||
|
case "top_count":
|
||||||
|
return "推荐数量"
|
||||||
|
case "keyword_count":
|
||||||
|
return "关键词数量"
|
||||||
|
case "depth":
|
||||||
|
return "深度"
|
||||||
|
case "article_outline":
|
||||||
|
return "文章大纲"
|
||||||
|
case "outline_sections":
|
||||||
|
return "已选段落"
|
||||||
|
case "key_points":
|
||||||
|
return "关键要点"
|
||||||
|
case "review_intro_hook":
|
||||||
|
return "评测引言钩子"
|
||||||
|
case "template_key":
|
||||||
|
return "模板标识"
|
||||||
|
case "template_name":
|
||||||
|
return "模板名称"
|
||||||
|
case "current_year":
|
||||||
|
return "当前年份"
|
||||||
|
case "input_params":
|
||||||
|
return "输入参数"
|
||||||
|
default:
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func formatPromptContextValue(key string, value interface{}) string {
|
func formatPromptContextValue(key string, value interface{}) string {
|
||||||
switch key {
|
switch key {
|
||||||
case "keywords":
|
case "keywords":
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildPromptContextUsesChineseLabels(t *testing.T) {
|
||||||
|
context := buildPromptContext(map[string]interface{}{
|
||||||
|
"locale": "zh-CN",
|
||||||
|
"title": "测试标题",
|
||||||
|
"brand_name": "Rankly",
|
||||||
|
"primary_keyword": "GEO 排名",
|
||||||
|
"outline_sections": []string{"引言", "结论"},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, expected := range []string{
|
||||||
|
"- 语言: zh-CN",
|
||||||
|
"- 标题: 测试标题",
|
||||||
|
"- 品牌名: Rankly",
|
||||||
|
"- 核心关键词: GEO 排名",
|
||||||
|
"- 已选段落: 引言 > 结论",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(context, expected) {
|
||||||
|
t.Fatalf("buildPromptContext() = %q, want %q", context, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildGenerationLengthGuidanceForEnglishLocaleIsWrittenInChinese(t *testing.T) {
|
||||||
|
guidance := buildGenerationLengthGuidance(map[string]interface{}{
|
||||||
|
"locale": "en-US",
|
||||||
|
"outline_sections": []string{"Intro", "Section 1", "Section 2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, unexpected := range []string{
|
||||||
|
"Intro and conclusion should stay concise",
|
||||||
|
"If one paragraph can make the point clearly",
|
||||||
|
"English words",
|
||||||
|
} {
|
||||||
|
if strings.Contains(guidance, unexpected) {
|
||||||
|
t.Fatalf("buildGenerationLengthGuidance() = %q, contains unexpected English prompt text %q", guidance, unexpected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildGenerationPromptAddsTopXBrandPriorityRules(t *testing.T) {
|
||||||
|
prompt := buildGenerationPrompt("top_x_article", "Top X 推荐文章", nil, map[string]interface{}{
|
||||||
|
"topic": "合肥全屋定制",
|
||||||
|
"brand_name": "安徽海翔家居用品销售有限公司",
|
||||||
|
"locale": "zh-CN",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, expected := range []string{
|
||||||
|
"模板专项要求:",
|
||||||
|
"安徽海翔家居用品销售有限公司",
|
||||||
|
"排在第 1 位",
|
||||||
|
"篇幅应明显多于其他对象",
|
||||||
|
"结论部分先明确推荐",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(prompt, expected) {
|
||||||
|
t.Fatalf("buildGenerationPrompt() = %q, want %q", prompt, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildGenerationPromptDoesNotAddTopXBrandRulesForOtherTemplates(t *testing.T) {
|
||||||
|
prompt := buildGenerationPrompt("product_review", "产品评测", nil, map[string]interface{}{
|
||||||
|
"product_name": "测试产品",
|
||||||
|
"brand_name": "安徽海翔家居用品销售有限公司",
|
||||||
|
})
|
||||||
|
|
||||||
|
if strings.Contains(prompt, "排在第 1 位") {
|
||||||
|
t.Fatalf("buildGenerationPrompt() = %q, should not include top-x ranking rules", prompt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ type generationJob struct {
|
|||||||
ArticleID int64
|
ArticleID int64
|
||||||
TaskID int64
|
TaskID int64
|
||||||
ReservationID int64
|
ReservationID int64
|
||||||
|
TemplateKey string
|
||||||
TemplateName string
|
TemplateName string
|
||||||
PromptTemplate *string
|
PromptTemplate *string
|
||||||
Params map[string]interface{}
|
Params map[string]interface{}
|
||||||
@@ -192,11 +193,11 @@ func effectivePromptVisibility(record *repository.TemplateRecord, actorTenantID
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GenerateRequest struct {
|
type GenerateRequest struct {
|
||||||
ArticleID *int64 `json:"article_id"`
|
ArticleID *int64 `json:"article_id"`
|
||||||
InputParams map[string]interface{} `json:"input_params" binding:"required"`
|
InputParams map[string]interface{} `json:"input_params" binding:"required"`
|
||||||
WizardState map[string]interface{} `json:"wizard_state"`
|
WizardState map[string]interface{} `json:"wizard_state"`
|
||||||
EnableWebSearch bool `json:"enable_web_search"`
|
EnableWebSearch bool `json:"enable_web_search"`
|
||||||
WebSearchLimit *int32 `json:"web_search_limit"`
|
WebSearchLimit *int32 `json:"web_search_limit"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerateResponse struct {
|
type GenerateResponse struct {
|
||||||
@@ -385,6 +386,7 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
|||||||
ArticleID: articleID,
|
ArticleID: articleID,
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
ReservationID: reservationID,
|
ReservationID: reservationID,
|
||||||
|
TemplateKey: templateRecord.TemplateKey,
|
||||||
TemplateName: templateRecord.TemplateName,
|
TemplateName: templateRecord.TemplateName,
|
||||||
PromptTemplate: templateRecord.PromptTemplate,
|
PromptTemplate: templateRecord.PromptTemplate,
|
||||||
Params: cloneInputParams(req.InputParams),
|
Params: cloneInputParams(req.InputParams),
|
||||||
@@ -467,7 +469,7 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
|||||||
StartedAt: &now,
|
StartedAt: &now,
|
||||||
})
|
})
|
||||||
|
|
||||||
prompt := buildGenerationPrompt(job.TemplateName, job.PromptTemplate, job.Params)
|
prompt := buildGenerationPrompt(job.TemplateKey, job.TemplateName, job.PromptTemplate, job.Params)
|
||||||
generateReq := llm.GenerateRequest{
|
generateReq := llm.GenerateRequest{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
Timeout: s.articleTimeout,
|
Timeout: s.articleTimeout,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
UPDATE article_templates
|
||||||
|
SET prompt_template = $$你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。
|
||||||
|
文章目标:
|
||||||
|
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
|
||||||
|
2. 说明每个对象的核心特点、适用场景、优缺点和选择建议。
|
||||||
|
|
||||||
|
写作要求:
|
||||||
|
- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。
|
||||||
|
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
|
||||||
|
- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。
|
||||||
|
- 引言先交代推荐标准与读者能得到什么,结尾给出简洁明确的选择建议。$$,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE scope = 'platform'
|
||||||
|
AND template_key = 'top_x_article'
|
||||||
|
AND deleted_at IS NULL;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
UPDATE article_templates
|
||||||
|
SET prompt_template = $$你是一名经验丰富的内容编辑,请围绕「{{topic}}」撰写一篇完整的 Markdown 推荐类文章。
|
||||||
|
文章目标:
|
||||||
|
1. 帮读者快速理解这个主题下值得关注的 {{count}} 个方向、产品或对象。
|
||||||
|
2. 说明每个对象的核心特点、适用场景、优缺点和选择建议。
|
||||||
|
|
||||||
|
写作要求:
|
||||||
|
- 不要写成空洞的排行列表;每个对象都要有清晰的入选理由和判断依据。
|
||||||
|
- 如果提供了 competitors、品牌或关键词上下文,要自然融入横向比较,但不要机械重复名单。
|
||||||
|
- 如果提供了 brand_name,且文章属于品牌、公司或服务商对比型推荐内容,应将该品牌作为主推对象:默认排在第 1 位、正文篇幅明显多于其他对象、结论优先推荐。
|
||||||
|
- 对 brand_name 对应对象重点展开核心优势、本地服务、适合人群、报价或配置亮点、交付安装、售后与避坑建议;其他对象保持客观但更简洁的差异化介绍。
|
||||||
|
- 重点写筛选标准、差异点、适合谁、不适合谁,以及常见误区或避坑点。
|
||||||
|
- 引言先交代推荐标准与读者能得到什么,结尾优先总结 brand_name 为什么值得优先考虑,再补充其他对象各自适合的预算与需求。$$,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE scope = 'platform'
|
||||||
|
AND template_key = 'top_x_article'
|
||||||
|
AND deleted_at IS NULL;
|
||||||
Reference in New Issue
Block a user