Commit 38b26a3b by YeYuheng Committed by GitHub

Use JSON extraction for content extract node (#7117)

parent 7d5b6a5c
...@@ -41,13 +41,16 @@ export const getExtractJsonPrompt = ({ ...@@ -41,13 +41,16 @@ export const getExtractJsonPrompt = ({
memory ? '【历史提取结果】' : '' memory ? '【历史提取结果】' : ''
].filter(Boolean); ].filter(Boolean);
const prompt = `## 背景 const prompt = `## 背景
用户需要执行一个函数,该函数需要一些参数,需要你结合${list.join('、')},来生成对应的参数 用户需要从文本中提取结构化信息,需要你结合${list.join('、')},按 JSON Schema 生成对应字段值
## 基本要求 ## 基本要求
- 严格根据 JSON Schema 的描述来生成参数。 - 严格根据 JSON Schema 的字段名、字段描述、字段类型和枚举值提取字段值。
- 不是每个参数都是必须生成的,如果没有合适的参数值,不要生成该参数,或返回空字符串。 - 输出字段只能来自 JSON Schema,不要新增字段,不要修改字段名。
- 需要结合历史记录,一起生成合适的参数。 - JSON Schema required 中声明的字段必须生成;如果没有可靠字段值,返回空字符串。
- 非 required 字段如果没有可靠字段值,可以省略该字段或返回空字符串。
- 不要编造用户输入、历史记录、背景知识和历史提取结果中无法支持的信息。
- 需要结合历史记录、用户输入、背景知识和历史提取结果提取合适的字段值,最新用户输入优先。
${ ${
systemPrompt systemPrompt
...@@ -69,8 +72,9 @@ ${schema} ...@@ -69,8 +72,9 @@ ${schema}
## 输出要求 ## 输出要求
- 严格输出 json 字符串。 - 严格只输出一个 JSON object 字符串。
- 不要回答问题。`.replace(/\n{3,}/g, '\n\n'); - 不要输出 Markdown、代码块、解释、前后缀文本或回答问题。
- JSON 必须可以被标准 JSON/JSON5 解析。`.replace(/\n{3,}/g, '\n\n');
return prompt; return prompt;
}; };
......
...@@ -44,6 +44,14 @@ describe('getExtractJsonPrompt', () => { ...@@ -44,6 +44,14 @@ describe('getExtractJsonPrompt', () => {
expect(result).toContain('## JSON Schema'); expect(result).toContain('## JSON Schema');
}); });
it('should describe text extraction instead of function calling', () => {
const result = getExtractJsonPrompt({});
expect(result).toContain('从文本中提取结构化信息');
expect(result).toContain('按 JSON Schema 生成对应字段值');
expect(result).not.toContain('执行一个函数');
expect(result).not.toContain('生成对应的参数');
});
it('should include systemPrompt section when systemPrompt is provided', () => { it('should include systemPrompt section when systemPrompt is provided', () => {
const result = getExtractJsonPrompt({ systemPrompt: '请提取用户姓名' }); const result = getExtractJsonPrompt({ systemPrompt: '请提取用户姓名' });
expect(result).toContain('【背景知识】'); expect(result).toContain('【背景知识】');
...@@ -95,8 +103,23 @@ describe('getExtractJsonPrompt', () => { ...@@ -95,8 +103,23 @@ describe('getExtractJsonPrompt', () => {
it('should always contain output requirements section', () => { it('should always contain output requirements section', () => {
const result = getExtractJsonPrompt({}); const result = getExtractJsonPrompt({});
expect(result).toContain('严格输出 json 字符串'); expect(result).toContain('严格只输出一个 JSON object 字符串');
expect(result).toContain('不要回答问题'); expect(result).toContain('不要输出 Markdown、代码块、解释、前后缀文本或回答问题');
});
it('should constrain plain JSON extraction format and fields', () => {
const result = getExtractJsonPrompt({
schema: '{"type":"object","properties":{"name":{"type":"string"}}}'
});
expect(result).toContain('输出字段只能来自 JSON Schema');
expect(result).toContain('不要新增字段');
expect(result).toContain('不要修改字段名');
expect(result).toContain('JSON Schema required 中声明的字段必须生成');
expect(result).toContain('非 required 字段如果没有可靠字段值,可以省略');
expect(result).toContain('字段类型和枚举值');
expect(result).toContain('不要编造');
expect(result).toContain('最新用户输入优先');
}); });
}); });
......
import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt'; import { chats2GPTMessages } from '@fastgpt/global/core/chat/adapt';
import { filterGPTMessageByMaxContext } from '../../../ai/llm/utils';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ContextExtractAgentItemType } from '@fastgpt/global/core/workflow/template/system/contextExtract/type'; import type { ContextExtractAgentItemType } from '@fastgpt/global/core/workflow/template/system/contextExtract/type';
...@@ -20,12 +19,8 @@ import json5 from 'json5'; ...@@ -20,12 +19,8 @@ import json5 from 'json5';
import { getLogger, LogCategories } from '../../../../common/logger'; import { getLogger, LogCategories } from '../../../../common/logger';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.AI); const logger = getLogger(LogCategories.MODULE.WORKFLOW.AI);
import { type ChatCompletionTool } from '@fastgpt/global/core/ai/llm/type';
import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
import { import { getExtractJsonPrompt } from '@fastgpt/global/core/ai/prompt/agent';
getExtractJsonPrompt,
getExtractJsonToolPrompt
} from '@fastgpt/global/core/ai/prompt/agent';
import { createLLMResponse } from '../../../ai/llm/request'; import { createLLMResponse } from '../../../ai/llm/request';
import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema'; import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema';
...@@ -44,8 +39,6 @@ type Response = DispatchNodeResultType<{ ...@@ -44,8 +39,6 @@ type Response = DispatchNodeResultType<{
type ActionProps = Props & { extractModel: LLMModelItemType; lastMemory?: Record<string, any> }; type ActionProps = Props & { extractModel: LLMModelItemType; lastMemory?: Record<string, any> };
const agentFunName = 'request_function';
export async function dispatchContentExtract(props: Props): Promise<Response> { export async function dispatchContentExtract(props: Props): Promise<Response> {
const { const {
runningAppInfo, runningAppInfo,
...@@ -69,22 +62,12 @@ export async function dispatchContentExtract(props: Props): Promise<Response> { ...@@ -69,22 +62,12 @@ export async function dispatchContentExtract(props: Props): Promise<Response> {
>; >;
try { try {
const { arg, inputTokens, outputTokens, usedUserOpenAIKey } = await (async () => { const { arg, inputTokens, outputTokens, usedUserOpenAIKey } = await completions({
if (extractModel.toolChoice) {
return toolChoice({
...props, ...props,
histories: chatHistories, histories: chatHistories,
extractModel, extractModel,
lastMemory lastMemory
}); });
}
return completions({
...props,
histories: chatHistories,
extractModel,
lastMemory
});
})();
// remove invalid key // remove invalid key
for (const key in arg) { for (const key in arg) {
...@@ -174,109 +157,6 @@ const getJsonSchema = ({ params: { extractKeys } }: ActionProps) => { ...@@ -174,109 +157,6 @@ const getJsonSchema = ({ params: { extractKeys } }: ActionProps) => {
return properties; return properties;
}; };
const toolChoice = async (props: ActionProps) => {
const {
externalProvider,
extractModel,
histories,
params: { content, description },
lastMemory
} = props;
const messages: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.System,
value: [
{
text: {
content: getExtractJsonToolPrompt({
systemPrompt: description,
memory: lastMemory ? JSON.stringify(lastMemory) : undefined
})
}
}
]
},
...histories,
{
obj: ChatRoleEnum.Human,
value: [
{
text: {
content
}
}
]
}
];
const adaptMessages = chats2GPTMessages({
messages,
reserveId: false,
reserveReason: false
});
const filterMessages = await filterGPTMessageByMaxContext({
messages: adaptMessages,
maxContext: extractModel.maxContext
});
const schema = getJsonSchema(props);
const tools: ChatCompletionTool[] = [
{
type: 'function',
function: {
name: agentFunName,
description: '需要执行的函数',
parameters: {
type: 'object',
properties: schema,
required: []
}
}
}
];
const body = {
stream: true,
model: extractModel.model,
messages: filterMessages,
tools,
tool_choice: { type: 'function', function: { name: agentFunName } },
toolCallMode: 'toolChoice',
...(extractModel.reasoning ? { reasoning_effort: 'none' as const } : {})
} as const;
const {
answerText: text,
toolCalls,
usage: { inputTokens, outputTokens, usedUserOpenAIKey }
} = await createLLMResponse({
body,
userKey: externalProvider.openaiAccount
});
const arg: Record<string, any> = (() => {
try {
return json5.parse(toolCalls?.[0]?.function?.arguments || text || '');
} catch (error) {
logger.warn('Failed to parse tool call arguments', {
body,
responseText: text,
toolCall: toolCalls?.[0]?.function,
error
});
return {};
}
})();
return {
inputTokens,
outputTokens,
usedUserOpenAIKey,
arg
};
};
const completions = async (props: ActionProps) => { const completions = async (props: ActionProps) => {
const { const {
extractModel, extractModel,
...@@ -315,6 +195,8 @@ const completions = async (props: ActionProps) => { ...@@ -315,6 +195,8 @@ const completions = async (props: ActionProps) => {
]; ];
const { const {
requestId,
finish_reason: finishReason,
answerText: answer, answerText: answer,
usage: { inputTokens, outputTokens, usedUserOpenAIKey } usage: { inputTokens, outputTokens, usedUserOpenAIKey }
} = await createLLMResponse({ } = await createLLMResponse({
...@@ -329,7 +211,26 @@ const completions = async (props: ActionProps) => { ...@@ -329,7 +211,26 @@ const completions = async (props: ActionProps) => {
// parse response // parse response
const jsonStr = sliceJsonStr(answer); const jsonStr = sliceJsonStr(answer);
logger.debug('Content extract LLM response received', {
requestId,
model: extractModel.model,
finishReason,
answerLength: answer.length,
jsonLength: jsonStr.length,
inputTokens,
outputTokens
});
if (!jsonStr) { if (!jsonStr) {
logger.warn('Content extract result has no JSON content', {
requestId,
model: extractModel.model,
finishReason,
answerLength: answer.length,
inputTokens,
outputTokens
});
return { return {
rawResponse: answer, rawResponse: answer,
inputTokens, inputTokens,
...@@ -348,7 +249,14 @@ const completions = async (props: ActionProps) => { ...@@ -348,7 +249,14 @@ const completions = async (props: ActionProps) => {
arg: json5.parse(jsonStr) as Record<string, any> arg: json5.parse(jsonStr) as Record<string, any>
}; };
} catch (error) { } catch (error) {
logger.warn('Failed to parse extract result', { answer, error }); logger.warn('Failed to parse extract result', {
requestId,
model: extractModel.model,
finishReason,
answerLength: answer.length,
jsonLength: jsonStr.length,
error
});
return { return {
rawResponse: answer, rawResponse: answer,
inputTokens, inputTokens,
......
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
const { const { createLLMResponseMock, getLLMModelMock, formatModelChars2PointsMock } = vi.hoisted(() => ({
createLLMResponseMock,
filterGPTMessageByMaxContextMock,
getLLMModelMock,
formatModelChars2PointsMock
} = vi.hoisted(() => ({
createLLMResponseMock: vi.fn(), createLLMResponseMock: vi.fn(),
filterGPTMessageByMaxContextMock: vi.fn(),
getLLMModelMock: vi.fn(), getLLMModelMock: vi.fn(),
formatModelChars2PointsMock: vi.fn() formatModelChars2PointsMock: vi.fn()
})); }));
...@@ -19,10 +13,6 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({ ...@@ -19,10 +13,6 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({
createLLMResponse: createLLMResponseMock createLLMResponse: createLLMResponseMock
})); }));
vi.mock('@fastgpt/service/core/ai/llm/utils', () => ({
filterGPTMessageByMaxContext: filterGPTMessageByMaxContextMock
}));
vi.mock('@fastgpt/service/core/ai/model', () => ({ vi.mock('@fastgpt/service/core/ai/model', () => ({
getLLMModel: getLLMModelMock getLLMModel: getLLMModelMock
})); }));
...@@ -72,17 +62,59 @@ const createProps = () => ...@@ -72,17 +62,59 @@ const createProps = () =>
} }
}) as any; }) as any;
const createAfterSaleProps = () =>
({
...createProps(),
params: {
...createProps().params,
content: '客户李四反馈商品包装破损,但没有提供订单号和联系电话。他希望平台尽快处理。',
description: '从售后反馈中提取客户、订单、商品和问题信息',
extractKeys: [
{
key: 'customerName',
desc: '客户姓名',
required: true
},
{
key: 'orderNo',
desc: '订单号',
required: true
},
{
key: 'productName',
desc: '商品名称',
required: true
},
{
key: 'issue',
desc: '客户反馈的问题',
required: true
}
]
}
}) as any;
const mockLLMResponse = (answerText: string) => ({
requestId: 'request_1',
finish_reason: 'stop',
answerText,
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
describe('dispatchContentExtract', () => { describe('dispatchContentExtract', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
filterGPTMessageByMaxContextMock.mockImplementation(async ({ messages }) => messages);
formatModelChars2PointsMock.mockReturnValue({ formatModelChars2PointsMock.mockReturnValue({
totalPoints: 1, totalPoints: 1,
modelName: 'DeepSeek R1' modelName: 'DeepSeek R1'
}); });
}); });
it('forces reasoning models to disable reasoning in tool choice extraction', async () => { it('uses plain JSON extraction even when the model supports tool choice', async () => {
getLLMModelMock.mockReturnValue({ getLLMModelMock.mockReturnValue({
model: 'deepseek-r1', model: 'deepseek-r1',
name: 'DeepSeek R1', name: 'DeepSeek R1',
...@@ -90,28 +122,21 @@ describe('dispatchContentExtract', () => { ...@@ -90,28 +122,21 @@ describe('dispatchContentExtract', () => {
reasoning: true, reasoning: true,
toolChoice: true toolChoice: true
}); });
createLLMResponseMock.mockResolvedValue({ createLLMResponseMock.mockResolvedValue(mockLLMResponse('{"name":"张三"}'));
answerText: '',
toolCalls: [
{
function: {
arguments: '{"name":"张三"}'
}
}
],
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
await dispatchContentExtract(createProps()); const result = await dispatchContentExtract(createProps());
expect(createLLMResponseMock.mock.calls[0][0].body).toMatchObject({ expect(createLLMResponseMock.mock.calls[0][0].body).toMatchObject({
model: 'deepseek-r1', model: 'deepseek-r1',
reasoning_effort: 'none', stream: true
toolCallMode: 'toolChoice' });
expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('tools');
expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('tool_choice');
expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('toolCallMode');
expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('reasoning_effort');
expect(result.data).toMatchObject({
success: true,
name: '张三'
}); });
}); });
...@@ -123,14 +148,7 @@ describe('dispatchContentExtract', () => { ...@@ -123,14 +148,7 @@ describe('dispatchContentExtract', () => {
reasoning: true, reasoning: true,
toolChoice: false toolChoice: false
}); });
createLLMResponseMock.mockResolvedValue({ createLLMResponseMock.mockResolvedValue(mockLLMResponse('{"name":"张三"}'));
answerText: '{"name":"张三"}',
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
await dispatchContentExtract(createProps()); await dispatchContentExtract(createProps());
...@@ -148,17 +166,86 @@ describe('dispatchContentExtract', () => { ...@@ -148,17 +166,86 @@ describe('dispatchContentExtract', () => {
reasoning: false, reasoning: false,
toolChoice: false toolChoice: false
}); });
createLLMResponseMock.mockResolvedValue({ createLLMResponseMock.mockResolvedValue(mockLLMResponse('{"name":"张三"}'));
answerText: '{"name":"张三"}',
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
await dispatchContentExtract(createProps()); await dispatchContentExtract(createProps());
expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('reasoning_effort'); expect(createLLMResponseMock.mock.calls[0][0].body).not.toHaveProperty('reasoning_effort');
}); });
it('keeps the existing plain JSON slicing behavior', async () => {
getLLMModelMock.mockReturnValue({
model: 'gpt-4o',
name: 'GPT-4o',
maxContext: 128000,
reasoning: false,
toolChoice: false
});
createLLMResponseMock.mockResolvedValue(mockLLMResponse('提取结果:{"name":"张三"}'));
const result = await dispatchContentExtract(createProps());
expect(result.data).toMatchObject({
success: true,
name: '张三'
});
});
it('returns default required fields when the model response has no JSON content', async () => {
getLLMModelMock.mockReturnValue({
model: 'gpt-4o',
name: 'GPT-4o',
maxContext: 128000,
reasoning: false,
toolChoice: false
});
createLLMResponseMock.mockResolvedValue(mockLLMResponse('没有可提取的信息'));
const result = await dispatchContentExtract(createProps());
expect(result.data).toMatchObject({
success: true,
name: ''
});
});
it('extracts multiple fields and removes fields outside the schema', async () => {
getLLMModelMock.mockReturnValue({
model: 'gpt-4o',
name: 'GPT-4o',
maxContext: 128000,
reasoning: false,
toolChoice: false
});
createLLMResponseMock.mockResolvedValue(
mockLLMResponse(
JSON.stringify({
customerName: '李四',
orderNo: '',
productName: '',
issue: '商品包装破损,希望平台尽快处理',
phone: '13800138000'
})
)
);
const result = await dispatchContentExtract(createAfterSaleProps());
expect(result.data).toMatchObject({
success: true,
customerName: '李四',
orderNo: '',
productName: '',
issue: '商品包装破损,希望平台尽快处理'
});
expect(result.data).not.toHaveProperty('phone');
expect(result.data?.[NodeOutputKeyEnum.contextExtractFields]).toBe(
JSON.stringify({
customerName: '李四',
issue: '商品包装破损,希望平台尽快处理',
orderNo: '',
productName: ''
})
);
});
}); });
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment