Feat: Rewrite the Go-based extractor operator form. (#18600)

This commit is contained in:
balibabu
2026-08-21 13:22:52 +08:00
committed by GitHub
parent 2686c494e2
commit cf96d6430d
6 changed files with 345 additions and 270 deletions

View File

@@ -361,8 +361,8 @@ export const initialExtractorValues = {
},
};
// Defaults for the Go backend extractor (nested per-feature configs plus
// legacy flat fields, which the Go schema still accepts).
// Defaults for the Go backend extractor: the LLM settings plus the nested
// per-feature groups the Go schema reads (schema.ExtractorParam).
export const initialGoExtractorValues = {
...initialLlmBaseValues,
keywords: {
@@ -381,23 +381,11 @@ export const initialGoExtractorValues = {
enabled: false,
system_prompt: '',
},
metadata_config: {
metadata: {
enabled: false,
metadata: [],
built_in_metadata: [],
},
metadata: [],
built_in_metadata: [],
field_name: '',
auto_keywords: 0,
auto_questions: 0,
auto_tags: 0,
tag_file_id: '',
enable_summary: 0,
enable_metadata: 0,
keywords_sys_prompt: '',
questions_sys_prompt: '',
sys_prompt: '',
outputs: {
chunks: { type: 'Array<Object>', value: [] },
},

View File

@@ -20,13 +20,13 @@ import {
} from '@/components/auto-keywords-form-field';
import { LargeModelFormField } from '@/components/large-model-form-field';
import { LlmSettingSchema } from '@/components/llm-setting-items/next';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { SliderInputFormField } from '@/components/slider-input-form-field';
import { AsyncTreeSelect } from '@/components/ui/async-tree-select';
import { Button } from '@/components/ui/button';
import { Form } from '@/components/ui/form';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FormLayout } from '@/constants/form';
import { RAGFlowNodeType } from '@/interfaces/database/agent';
import { PromptEditor } from '@/pages/agent/form/components/prompt-editor';
@@ -82,34 +82,21 @@ export const FormSchema = z.object({
system_prompt: z.string().optional(),
})
.optional(),
metadata_config: z
// Matches schema.ExtractorParam.Metadata on the Go side
// (internal/ingestion/component/schema/extractor.go).
metadata: z
.object({
enabled: z.union([z.number(), z.boolean()]).optional(),
metadata: z.any().optional(),
built_in_metadata: z.any().optional(),
})
.optional(),
// Legacy flat fields for backward compatibility
field_name: z.string().optional(),
sys_prompt: z.string().optional(),
prompts: z.string().optional(),
keywords_sys_prompt: z.string().optional(),
questions_sys_prompt: z.string().optional(),
auto_keywords: z.number().optional(),
auto_questions: z.number().optional(),
auto_tags: z.number().optional(),
tag_file_id: z.string().optional(),
enable_summary: z.union([z.number(), z.boolean()]).optional(),
enable_metadata: z.number().optional(),
metadata: z.any().optional(),
built_in_metadata: z.any().optional(),
...LlmSettingSchema,
});
export type ExtractorFormSchemaType = z.infer<typeof FormSchema>;
enum ExtractorSubTab {
enum ExtractorSection {
Keywords = 'keywords',
Questions = 'questions',
Tags = 'tags',
@@ -118,9 +105,9 @@ enum ExtractorSubTab {
}
// ExtractorAutoMetadata mirrors Python's dataset "Auto metadata" control: an
// enable_metadata switch plus a field-schema editor (custom + built-in).
// Values are stored on the node params (enable_metadata / metadata /
// built_in_metadata) and drive the Go extractor's runEnableMetadata.
// enable switch plus a field-schema editor (custom + built-in). Values are
// stored as the nested `metadata` group ({enabled, metadata,
// built_in_metadata}) that the Go extractor's runEnableMetadata reads.
function ExtractorAutoMetadata() {
const { t } = useTranslation();
const form = useFormContext<ExtractorFormSchemaType>();
@@ -135,16 +122,11 @@ function ExtractorAutoMetadata() {
const handleOpen = useCallback(() => {
showManageMetadataModal({
metadata: util.metaDataSettingJSONToMetaDataTableData(
form.getValues('metadata_config.metadata') ||
form.getValues('metadata') ||
[],
form.getValues('metadata.metadata') || [],
),
isCanAdd: true,
type: MetadataType.Setting,
builtInMetadata:
form.getValues('metadata_config.built_in_metadata') ||
form.getValues('built_in_metadata') ||
[],
builtInMetadata: form.getValues('metadata.built_in_metadata') || [],
});
}, [form, showManageMetadataModal]);
@@ -153,19 +135,13 @@ function ExtractorAutoMetadata() {
metadata?: IMetaDataReturnJSONSettings;
builtInMetadata?: IBuiltInMetadataItem[];
}) => {
const metaList = data?.metadata || [];
const builtInList = data?.builtInMetadata || [];
form.setValue('metadata_config.metadata', metaList, {
form.setValue('metadata.metadata', data?.metadata || [], {
shouldDirty: true,
});
form.setValue('metadata_config.built_in_metadata', builtInList, {
form.setValue('metadata.built_in_metadata', data?.builtInMetadata || [], {
shouldDirty: true,
});
form.setValue('metadata_config.enabled', true, { shouldDirty: true });
// Also keep flat fields for backward compatibility
form.setValue('metadata', metaList, { shouldDirty: true });
form.setValue('built_in_metadata', builtInList, { shouldDirty: true });
form.setValue('enable_metadata', 1, { shouldDirty: true });
form.setValue('metadata.enabled', true, { shouldDirty: true });
},
[form],
);
@@ -174,7 +150,7 @@ function ExtractorAutoMetadata() {
<>
<RAGFlowFormItem
label={t('knowledgeConfiguration.autoMetadata')}
name="metadata_config.enabled"
name="metadata.enabled"
>
{(field) => (
<div className="flex items-center justify-between">
@@ -191,12 +167,7 @@ function ExtractorAutoMetadata() {
</Button>
<Switch
checked={field.value === 1 || field.value === true}
onCheckedChange={(checked) => {
field.onChange(checked);
form.setValue('enable_metadata', checked ? 1 : 0, {
shouldDirty: true,
});
}}
onCheckedChange={field.onChange}
data-testid="extractor-metadata-switch"
/>
</div>
@@ -249,12 +220,8 @@ const GoExtractorForm = ({
resolver: zodResolver(FormSchema),
});
useEffect(() => {
form.reset(defaultValues);
}, [defaultValues, form]);
const [activeTab, setActiveTab] = useState<ExtractorSubTab>(
ExtractorSubTab.Keywords,
const [activeSection, setActiveSection] = useState<ExtractorSection>(
ExtractorSection.Keywords,
);
useWatchFormChange(node?.id, form);
@@ -262,8 +229,7 @@ const GoExtractorForm = ({
const ownerTenantId = useOwnerTenantId();
const tagFileIdWatch =
form.watch('tags.tag_file_id') || form.watch('tag_file_id');
const tagFileIdWatch = form.watch('tags.tag_file_id');
const { treeData, loadData } = useTagFileTree(tagFileIdWatch);
useEffect(() => {
@@ -284,8 +250,22 @@ const GoExtractorForm = ({
}
}, [form, t]);
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab as ExtractorSubTab);
const sectionOptions = useMemo(
() => [
{ label: t('flow.keywords'), value: ExtractorSection.Keywords },
{ label: t('flow.questions'), value: ExtractorSection.Questions },
{
label: t('flow.tags') || t('knowledgeDetails.autoTags'),
value: ExtractorSection.Tags,
},
{ label: t('flow.summary'), value: ExtractorSection.Summary },
{ label: t('flow.metadata'), value: ExtractorSection.Metadata },
],
[t],
);
const handleSectionChange = useCallback((section: string) => {
setActiveSection(section as ExtractorSection);
}, []);
return (
@@ -295,130 +275,107 @@ const GoExtractorForm = ({
ownerTenantId={ownerTenantId}
></LargeModelFormField>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="w-full"
>
<TabsList className="w-full justify-start">
<TabsTrigger value={ExtractorSubTab.Keywords}>
{t('flow.keywords')}
</TabsTrigger>
<TabsTrigger value={ExtractorSubTab.Questions}>
{t('flow.questions')}
</TabsTrigger>
<TabsTrigger value={ExtractorSubTab.Tags}>
{t('flow.tags') || t('knowledgeDetails.autoTags')}
</TabsTrigger>
<TabsTrigger value={ExtractorSubTab.Summary}>
{t('flow.summary')}
</TabsTrigger>
<TabsTrigger value={ExtractorSubTab.Metadata}>
{t('flow.metadata')}
</TabsTrigger>
</TabsList>
<div>
<SelectWithSearch
value={activeSection}
onChange={handleSectionChange}
options={sectionOptions}
/>
<TabsContent
value={ExtractorSubTab.Keywords}
className="space-y-4 pt-2"
>
<AutoKeywordsFormField name="keywords.top_n" />
<RAGFlowFormItem
label={t('flow.systemPrompt')}
name="keywords.system_prompt"
>
<PromptEditor
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
showMergePath={false}
/>
</RAGFlowFormItem>
</TabsContent>
<div className="space-y-4 pt-4">
{activeSection === ExtractorSection.Keywords && (
<>
<AutoKeywordsFormField name="keywords.top_n" />
<RAGFlowFormItem
label={t('flow.systemPrompt')}
name="keywords.system_prompt"
>
<PromptEditor
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
showMergePath={false}
/>
</RAGFlowFormItem>
</>
)}
<TabsContent
value={ExtractorSubTab.Questions}
className="space-y-4 pt-2"
>
<AutoQuestionsFormField name="questions.top_n" />
<RAGFlowFormItem
label={t('flow.systemPrompt')}
name="questions.system_prompt"
>
<PromptEditor
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
showMergePath={false}
/>
</RAGFlowFormItem>
</TabsContent>
{activeSection === ExtractorSection.Questions && (
<>
<AutoQuestionsFormField name="questions.top_n" />
<RAGFlowFormItem
label={t('flow.systemPrompt')}
name="questions.system_prompt"
>
<PromptEditor
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
showMergePath={false}
/>
</RAGFlowFormItem>
</>
)}
<TabsContent value={ExtractorSubTab.Tags} className="space-y-4 pt-2">
<SliderInputFormField
name="tags.top_n"
label={t('knowledgeDetails.autoTags')}
min={0}
max={10}
defaultValue={0}
layout={FormLayout.Vertical}
/>
<RAGFlowFormItem label={t('flow.tagFile')} name="tags.tag_file_id">
{(field) => (
<AsyncTreeSelect
treeData={treeData}
value={field.value}
onChange={field.onChange}
loadData={loadData}
canSelect={canSelectTagFile}
{activeSection === ExtractorSection.Tags && (
<>
<SliderInputFormField
name="tags.top_n"
label={t('knowledgeDetails.autoTags')}
min={0}
max={10}
defaultValue={0}
layout={FormLayout.Vertical}
/>
)}
</RAGFlowFormItem>
</TabsContent>
<RAGFlowFormItem
label={t('flow.tagFile')}
name="tags.tag_file_id"
>
{(field) => (
<AsyncTreeSelect
treeData={treeData}
value={field.value}
onChange={field.onChange}
loadData={loadData}
canSelect={canSelectTagFile}
/>
)}
</RAGFlowFormItem>
</>
)}
<TabsContent
value={ExtractorSubTab.Summary}
className="space-y-4 pt-2"
>
<RAGFlowFormItem
label={t('flow.enableSummary')}
name="summary.enabled"
horizontal
valueClassName="w-auto flex justify-end"
>
{(field) => (
<Switch
checked={field.value === 1 || field.value === true}
onCheckedChange={(checked) => {
field.onChange(checked);
form.setValue('field_name', checked ? 'summary' : '', {
shouldDirty: true,
});
form.setValue('enable_summary', checked ? 1 : 0, {
shouldDirty: true,
});
}}
data-testid="extractor-summary-switch"
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
label={t('flow.systemPrompt')}
name="summary.system_prompt"
>
<PromptEditor
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
showMergePath={false}
/>
</RAGFlowFormItem>
</TabsContent>
{activeSection === ExtractorSection.Summary && (
<>
<RAGFlowFormItem
label={t('flow.enableSummary')}
name="summary.enabled"
horizontal
valueClassName="w-auto flex justify-end"
>
{(field) => (
<Switch
checked={field.value === 1 || field.value === true}
onCheckedChange={field.onChange}
data-testid="extractor-summary-switch"
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
label={t('flow.systemPrompt')}
name="summary.system_prompt"
>
<PromptEditor
placeholder={t('flow.messagePlaceholder')}
showToolbar={false}
showMergePath={false}
/>
</RAGFlowFormItem>
</>
)}
<TabsContent
value={ExtractorSubTab.Metadata}
className="space-y-4 pt-2"
>
<ExtractorAutoMetadata />
</TabsContent>
</Tabs>
{activeSection === ExtractorSection.Metadata && (
<ExtractorAutoMetadata />
)}
</div>
</div>
{!hideOutputs && <Output list={outputList}></Output>}
</FormWrapper>

View File

@@ -1,4 +1,5 @@
import { useFetchDefaultModelDictionary } from '@/hooks/use-llm-request';
import { isGoBackend } from '@/utils/backend-runtime';
import { Connection, Node, Position, ReactFlowInstance } from '@xyflow/react';
import humanId from 'human-id';
import { t } from 'i18next';
@@ -184,8 +185,15 @@ export const useInitializeOperatorParams = () => {
[Operator.Extractor]: {
...getInitialExtractorValues(),
llm_id: llmId,
sys_prompt: t('flow.prompts.system.summary'),
prompts: t('flow.prompts.user.summary'),
// sys_prompt/prompts belong to the Python extractor form. The Go
// form seeds summary.system_prompt itself, and the Go extractor
// falls back to a built-in prompt when it is empty.
...(isGoBackend()
? {}
: {
sys_prompt: t('flow.prompts.system.summary'),
prompts: t('flow.prompts.user.summary'),
}),
},
[Operator.Compiler]: { ...initialCompilationValues, llm_id: llmId },
[Operator.DataOperations]: initialDataOperationsValues,

View File

@@ -20,6 +20,7 @@ import {
isEmpty,
isEqual,
omit,
pick,
sample,
} from 'lodash';
import isObject from 'lodash/isObject';
@@ -399,82 +400,99 @@ export function transformTitleChunkerParams(
};
}
// LLM setting keys the Go extractor DSL keeps besides the nested groups.
// Mirrors LlmSettingSchema (components/llm-setting-items/next) — duplicated
// here as a plain list so this module doesn't import the form components.
const ExtractorLlmSettingKeys = [
'llm_id',
'temperature',
'top_p',
'presence_penalty',
'frequency_penalty',
'max_tokens',
'parameter',
'thinking',
'temperatureEnabled',
'topPEnabled',
'presencePenaltyEnabled',
'frequencyPenaltyEnabled',
'maxTokensEnabled',
];
export function transformExtractorParams(
params: ExtractorFormSchemaType,
): Record<string, any> {
): Record<string, any> { const raw = params as Record<string, any>;
// The Python extractor only reads the legacy flat fields; the nested
// per-feature configs below are Go-only.
if (!isGoBackend()) {
return { ...params, prompts: [{ content: params.prompts, role: 'user' }] };
return { ...params, prompts: [{ content: raw.prompts, role: 'user' }] };
}
// An unopened legacy node can still flow through here with flat keys
// (auto_keywords, keywords_sys_prompt, enable_metadata + metadata[],
// the transitional "metadata_config", ...). Accept them as read
// fallbacks — flat "metadata" is an array, which distinguishes it from
// the group object — but never re-emit them.
const metadataGroup =
raw.metadata !== undefined && !Array.isArray(raw.metadata)
? raw.metadata
: raw.metadata_config;
const isMetadataEnabled =
params.metadata_config?.enabled !== undefined
? Boolean(params.metadata_config?.enabled)
: params.enable_metadata === 1 || params.enable_metadata === true;
metadataGroup?.enabled !== undefined
? Boolean(metadataGroup.enabled)
: raw.enable_metadata === 1 || raw.enable_metadata === true;
const isSummaryEnabled =
params.summary?.enabled !== undefined
? Boolean(params.summary?.enabled)
: params.enable_summary === 1 ||
params.enable_summary === true ||
params.field_name === 'summary';
: raw.enable_summary === 1 ||
raw.enable_summary === true ||
raw.field_name === 'summary';
const metadataList =
params.metadata_config?.metadata ?? params.metadata ?? [];
metadataGroup?.metadata ??
(Array.isArray(raw.metadata) ? raw.metadata : []);
const builtInMetadataList =
params.metadata_config?.built_in_metadata ??
params.built_in_metadata ??
[];
metadataGroup?.built_in_metadata ?? raw.built_in_metadata ?? [];
const summarySysPrompt =
params.summary?.system_prompt ?? params.sys_prompt ?? '';
params.summary?.system_prompt ?? raw.sys_prompt ?? '';
const keywordsTopN = params.keywords?.top_n ?? params.auto_keywords ?? 0;
const keywordsTopN = params.keywords?.top_n ?? raw.auto_keywords ?? 0;
const keywordsSysPrompt =
params.keywords?.system_prompt ?? params.keywords_sys_prompt ?? '';
params.keywords?.system_prompt ?? raw.keywords_sys_prompt ?? '';
const questionsTopN = params.questions?.top_n ?? params.auto_questions ?? 0;
const questionsTopN = params.questions?.top_n ?? raw.auto_questions ?? 0;
const questionsSysPrompt =
params.questions?.system_prompt ?? params.questions_sys_prompt ?? '';
params.questions?.system_prompt ?? raw.questions_sys_prompt ?? '';
const tagsTopN = params.tags?.top_n ?? params.auto_tags ?? 0;
const tagFileId = params.tags?.tag_file_id ?? params.tag_file_id ?? '';
const tagsTopN = params.tags?.top_n ?? raw.auto_tags ?? 0;
const tagFileId = params.tags?.tag_file_id ?? raw.tag_file_id ?? '';
// The Go extractor (schema.ExtractorParam) reads only llm_id plus the
// nested per-feature groups, so emit exactly that whitelist along with
// the LLM settings the form defines — no legacy flat mirrors, no
// display-only fields like outputs.
return {
...params,
prompts: [{ content: params.prompts, role: 'user' }],
auto_keywords: keywordsTopN,
keywords_sys_prompt: keywordsSysPrompt,
...pick(params, ExtractorLlmSettingKeys),
keywords: {
top_n: keywordsTopN,
system_prompt: keywordsSysPrompt,
},
auto_questions: questionsTopN,
questions_sys_prompt: questionsSysPrompt,
questions: {
top_n: questionsTopN,
system_prompt: questionsSysPrompt,
},
auto_tags: tagsTopN,
tag_file_id: tagFileId,
tags: {
top_n: tagsTopN,
tag_file_id: tagFileId,
},
enable_summary: isSummaryEnabled ? 1 : 0,
sys_prompt: summarySysPrompt,
field_name: isSummaryEnabled
? (params.field_name || 'summary')
: (params.field_name === 'summary' ? '' : (params.field_name || '')),
summary: {
enabled: isSummaryEnabled,
system_prompt: summarySysPrompt,
},
enable_metadata: isMetadataEnabled ? 1 : 0,
metadata: metadataList,
built_in_metadata: builtInMetadataList,
metadata_config: {
metadata: {
enabled: isMetadataEnabled,
metadata: metadataList,
built_in_metadata: builtInMetadataList,

View File

@@ -13,13 +13,13 @@ describe('Extractor parameter transformations & precedence', () => {
});
describe('transformExtractorParams', () => {
it('synchronizes nested modular configs to flat fields and preserves nested objects', () => {
it('emits only the LLM settings and the nested groups the Go extractor reads', () => {
const input: any = {
summary: {
enabled: true,
system_prompt: 'Custom summary prompt',
},
metadata_config: {
metadata: {
enabled: true,
metadata: [{ key: 'category', type: 'string' }],
built_in_metadata: [{ key: 'update_time', type: 'time' }],
@@ -37,22 +37,109 @@ describe('Extractor parameter transformations & precedence', () => {
tag_file_id: 'tag-123',
},
llm_id: 'gpt-4',
temperature: 0.5,
temperatureEnabled: true,
// Fields that must not leak into the DSL params
outputs: { chunks: { type: 'Array<Object>', value: [] } },
prompts: 'user prompt',
sys_prompt: 'legacy sys',
field_name: 'summary',
auto_keywords: 9,
keywords_sys_prompt: 'legacy kw',
};
const result = transformExtractorParams(input);
expect(result.enable_summary).toBe(1);
expect(result.summary).toEqual({
enabled: true,
system_prompt: 'Custom summary prompt',
expect(result).toEqual({
llm_id: 'gpt-4',
temperature: 0.5,
temperatureEnabled: true,
keywords: {
top_n: 5,
system_prompt: 'KW prompt',
},
questions: {
top_n: 3,
system_prompt: 'Q prompt',
},
tags: {
top_n: 2,
tag_file_id: 'tag-123',
},
summary: {
enabled: true,
system_prompt: 'Custom summary prompt',
},
metadata: {
enabled: true,
metadata: [{ key: 'category', type: 'string' }],
built_in_metadata: [{ key: 'update_time', type: 'time' }],
},
});
expect(result.enable_metadata).toBe(1);
expect(result.metadata_config.enabled).toBe(true);
expect(result.metadata_config.metadata).toHaveLength(1);
expect(result.auto_keywords).toBe(5);
expect(result.auto_questions).toBe(3);
expect(result.auto_tags).toBe(2);
expect(result.tag_file_id).toBe('tag-123');
});
it('maps legacy flat fields into the nested groups without re-emitting them', () => {
const input: any = {
auto_keywords: 4,
keywords_sys_prompt: 'legacy kw prompt',
auto_questions: 2,
questions_sys_prompt: 'legacy q prompt',
auto_tags: 1,
tag_file_id: 'tag-legacy',
enable_summary: 1,
sys_prompt: 'legacy summary prompt',
};
const result = transformExtractorParams(input);
expect(result).toEqual({
keywords: { top_n: 4, system_prompt: 'legacy kw prompt' },
questions: { top_n: 2, system_prompt: 'legacy q prompt' },
tags: { top_n: 1, tag_file_id: 'tag-legacy' },
summary: { enabled: true, system_prompt: 'legacy summary prompt' },
metadata: { enabled: false, metadata: [], built_in_metadata: [] },
});
});
it('accepts legacy flat metadata fields from unopened legacy nodes', () => {
const input: any = {
enable_metadata: 1,
metadata: [{ key: 'author', type: 'string' }],
built_in_metadata: [{ key: 'file_name', type: 'string' }],
};
const result = transformExtractorParams(input);
expect(result).toEqual({
keywords: { top_n: 0, system_prompt: '' },
questions: { top_n: 0, system_prompt: '' },
tags: { top_n: 0, tag_file_id: '' },
summary: { enabled: false, system_prompt: '' },
metadata: {
enabled: true,
metadata: [{ key: 'author', type: 'string' }],
built_in_metadata: [{ key: 'file_name', type: 'string' }],
},
});
});
it('accepts the transitional metadata_config key', () => {
const input: any = {
metadata_config: {
enabled: true,
metadata: [{ key: 'category', type: 'string' }],
built_in_metadata: [],
},
};
const result = transformExtractorParams(input);
expect(result.metadata).toEqual({
enabled: true,
metadata: [{ key: 'category', type: 'string' }],
built_in_metadata: [],
});
expect(result).not.toHaveProperty('metadata_config');
});
it('gives nested modular enabled: false precedence over legacy flat enable_*: 1', () => {
@@ -62,7 +149,7 @@ describe('Extractor parameter transformations & precedence', () => {
system_prompt: '',
},
enable_summary: 1,
metadata_config: {
metadata: {
enabled: false,
metadata: [],
built_in_metadata: [],
@@ -73,22 +160,10 @@ describe('Extractor parameter transformations & precedence', () => {
const result = transformExtractorParams(input);
expect(result.summary.enabled).toBe(false);
expect(result.enable_summary).toBe(0);
expect(result.metadata_config.enabled).toBe(false);
expect(result.enable_metadata).toBe(0);
});
it('preserves custom field_name when summary is disabled', () => {
const input: any = {
summary: {
enabled: false,
system_prompt: '',
},
field_name: 'custom_chunk_field',
};
const result = transformExtractorParams(input);
expect(result.field_name).toBe('custom_chunk_field');
expect(result.metadata.enabled).toBe(false);
expect(result).not.toHaveProperty('enable_summary');
expect(result).not.toHaveProperty('enable_metadata');
expect(result).not.toHaveProperty('built_in_metadata');
});
});
@@ -112,15 +187,36 @@ describe('Extractor parameter transformations & precedence', () => {
enabled: true,
system_prompt: 'Old summary prompt',
});
expect(result.metadata_config).toEqual({
expect(result.metadata).toEqual({
enabled: true,
metadata: [{ key: 'author', type: 'string' }],
built_in_metadata: [{ key: 'file_name', type: 'string' }],
});
expect(result).not.toHaveProperty('metadata_config');
expect(result).not.toHaveProperty('enable_metadata');
expect(result).not.toHaveProperty('built_in_metadata');
expect(result.keywords.top_n).toBe(4);
expect(result.questions.top_n).toBe(2);
expect(result.tags.top_n).toBe(1);
});
it('passes through the metadata group shape unchanged', () => {
const config = {
metadata: {
enabled: true,
metadata: [{ key: 'category', type: 'string', enum: ['科技'] }],
built_in_metadata: [{ key: 'doc_name', type: 'string' }],
},
};
const result = transformExtractorConfigToForm(config);
expect(result.metadata).toEqual({
enabled: true,
metadata: [{ key: 'category', type: 'string', enum: ['科技'] }],
built_in_metadata: [{ key: 'doc_name', type: 'string' }],
});
});
});
describe('python backend keeps the legacy flat shape', () => {

View File

@@ -109,9 +109,19 @@ export function transformExtractorConfigToForm(
? Boolean(config.summary?.enabled)
: config.enable_summary === 1 || config.enable_summary === true;
// The metadata group is stored under "metadata" — the shape the Go
// Extractor reads (schema.ExtractorParam). Accept two historical shapes:
// the transitional "metadata_config" key, and legacy flat nodes carrying
// enable_metadata + metadata[] + built_in_metadata[] (flat "metadata" is
// an array there, which distinguishes it from the group object).
const metadataGroup =
config.metadata !== undefined && !Array.isArray(config.metadata)
? config.metadata
: config.metadata_config;
const isMetadataEnabled =
config.metadata_config?.enabled !== undefined
? Boolean(config.metadata_config?.enabled)
metadataGroup?.enabled !== undefined
? Boolean(metadataGroup.enabled)
: config.enable_metadata === 1 || config.enable_metadata === true;
result.keywords = {
@@ -141,22 +151,20 @@ export function transformExtractorConfigToForm(
enabled: isSummaryEnabled,
system_prompt: config.summary?.system_prompt ?? config.sys_prompt ?? '',
};
result.enable_summary = isSummaryEnabled ? 1 : 0;
result.field_name = isSummaryEnabled
? (config.field_name || 'summary')
: (config.field_name === 'summary' ? '' : (config.field_name || ''));
result.metadata_config = {
result.metadata = {
enabled: isMetadataEnabled,
metadata: config.metadata_config?.metadata ?? config.metadata ?? [],
metadata:
metadataGroup?.metadata ??
(Array.isArray(config.metadata) ? config.metadata : []),
built_in_metadata:
config.metadata_config?.built_in_metadata ??
config.built_in_metadata ??
[],
metadataGroup?.built_in_metadata ?? config.built_in_metadata ?? [],
};
result.enable_metadata = isMetadataEnabled ? 1 : 0;
result.metadata = result.metadata_config.metadata;
result.built_in_metadata = result.metadata_config.built_in_metadata;
// Drop the historical keys so they don't linger in form state and get
// re-emitted into the DSL on save — the Go extractor only reads the group.
delete result.metadata_config;
delete result.enable_metadata;
delete result.built_in_metadata;
return result;
}