Commit 38b26a3b by YeYuheng Committed by GitHub

Use JSON extraction for content extract node (#7117)

parent 7d5b6a5c
......@@ -41,13 +41,16 @@ export const getExtractJsonPrompt = ({
memory ? '【历史提取结果】' : ''
].filter(Boolean);
const prompt = `## 背景
用户需要执行一个函数,该函数需要一些参数,需要你结合${list.join('、')},来生成对应的参数
用户需要从文本中提取结构化信息,需要你结合${list.join('、')},按 JSON Schema 生成对应字段值
## 基本要求
- 严格根据 JSON Schema 的描述来生成参数。
- 不是每个参数都是必须生成的,如果没有合适的参数值,不要生成该参数,或返回空字符串。
- 需要结合历史记录,一起生成合适的参数。
- 严格根据 JSON Schema 的字段名、字段描述、字段类型和枚举值提取字段值。
- 输出字段只能来自 JSON Schema,不要新增字段,不要修改字段名。
- JSON Schema required 中声明的字段必须生成;如果没有可靠字段值,返回空字符串。
- 非 required 字段如果没有可靠字段值,可以省略该字段或返回空字符串。
- 不要编造用户输入、历史记录、背景知识和历史提取结果中无法支持的信息。
- 需要结合历史记录、用户输入、背景知识和历史提取结果提取合适的字段值,最新用户输入优先。
${
systemPrompt
......@@ -69,8 +72,9 @@ ${schema}
## 输出要求
- 严格输出 json 字符串。
- 不要回答问题。`.replace(/\n{3,}/g, '\n\n');
- 严格只输出一个 JSON object 字符串。
- 不要输出 Markdown、代码块、解释、前后缀文本或回答问题。
- JSON 必须可以被标准 JSON/JSON5 解析。`.replace(/\n{3,}/g, '\n\n');
return prompt;
};
......
......@@ -44,6 +44,14 @@ describe('getExtractJsonPrompt', () => {
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', () => {
const result = getExtractJsonPrompt({ systemPrompt: '请提取用户姓名' });
expect(result).toContain('【背景知识】');
......@@ -95,8 +103,23 @@ describe('getExtractJsonPrompt', () => {
it('should always contain output requirements section', () => {
const result = getExtractJsonPrompt({});
expect(result).toContain('严格输出 json 字符串');
expect(result).toContain('不要回答问题');
expect(result).toContain('严格只输出一个 JSON object 字符串');
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 { filterGPTMessageByMaxContext } from '../../../ai/llm/utils';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import type { ContextExtractAgentItemType } from '@fastgpt/global/core/workflow/template/system/contextExtract/type';
......@@ -20,12 +19,8 @@ import json5 from 'json5';
import { getLogger, LogCategories } from '../../../../common/logger';
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 {
getExtractJsonPrompt,
getExtractJsonToolPrompt
} from '@fastgpt/global/core/ai/prompt/agent';
import { getExtractJsonPrompt } from '@fastgpt/global/core/ai/prompt/agent';
import { createLLMResponse } from '../../../ai/llm/request';
import type { JsonSchemaPropertiesItemType } from '@fastgpt/global/core/app/jsonschema';
......@@ -44,8 +39,6 @@ type Response = DispatchNodeResultType<{
type ActionProps = Props & { extractModel: LLMModelItemType; lastMemory?: Record<string, any> };
const agentFunName = 'request_function';
export async function dispatchContentExtract(props: Props): Promise<Response> {
const {
runningAppInfo,
......@@ -69,22 +62,12 @@ export async function dispatchContentExtract(props: Props): Promise<Response> {
>;
try {
const { arg, inputTokens, outputTokens, usedUserOpenAIKey } = await (async () => {
if (extractModel.toolChoice) {
return toolChoice({
...props,
histories: chatHistories,
extractModel,
lastMemory
});
}
return completions({
...props,
histories: chatHistories,
extractModel,
lastMemory
});
})();
const { arg, inputTokens, outputTokens, usedUserOpenAIKey } = await completions({
...props,
histories: chatHistories,
extractModel,
lastMemory
});
// remove invalid key
for (const key in arg) {
......@@ -174,109 +157,6 @@ const getJsonSchema = ({ params: { extractKeys } }: ActionProps) => {
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 {
extractModel,
......@@ -315,6 +195,8 @@ const completions = async (props: ActionProps) => {
];
const {
requestId,
finish_reason: finishReason,
answerText: answer,
usage: { inputTokens, outputTokens, usedUserOpenAIKey }
} = await createLLMResponse({
......@@ -329,7 +211,26 @@ const completions = async (props: ActionProps) => {
// parse response
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) {
logger.warn('Content extract result has no JSON content', {
requestId,
model: extractModel.model,
finishReason,
answerLength: answer.length,
inputTokens,
outputTokens
});
return {
rawResponse: answer,
inputTokens,
......@@ -348,7 +249,14 @@ const completions = async (props: ActionProps) => {
arg: json5.parse(jsonStr) as Record<string, any>
};
} 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 {
rawResponse: answer,
inputTokens,
......
import { beforeEach, describe, expect, it, vi } from 'vitest';
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';
const {
createLLMResponseMock,
filterGPTMessageByMaxContextMock,
getLLMModelMock,
formatModelChars2PointsMock
} = vi.hoisted(() => ({
const { createLLMResponseMock, getLLMModelMock, formatModelChars2PointsMock } = vi.hoisted(() => ({
createLLMResponseMock: vi.fn(),
filterGPTMessageByMaxContextMock: vi.fn(),
getLLMModelMock: vi.fn(),
formatModelChars2PointsMock: vi.fn()
}));
......@@ -19,10 +13,6 @@ vi.mock('@fastgpt/service/core/ai/llm/request', () => ({
createLLMResponse: createLLMResponseMock
}));
vi.mock('@fastgpt/service/core/ai/llm/utils', () => ({
filterGPTMessageByMaxContext: filterGPTMessageByMaxContextMock
}));
vi.mock('@fastgpt/service/core/ai/model', () => ({
getLLMModel: getLLMModelMock
}));
......@@ -72,17 +62,59 @@ const createProps = () =>
}
}) 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', () => {
beforeEach(() => {
vi.clearAllMocks();
filterGPTMessageByMaxContextMock.mockImplementation(async ({ messages }) => messages);
formatModelChars2PointsMock.mockReturnValue({
totalPoints: 1,
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({
model: 'deepseek-r1',
name: 'DeepSeek R1',
......@@ -90,28 +122,21 @@ describe('dispatchContentExtract', () => {
reasoning: true,
toolChoice: true
});
createLLMResponseMock.mockResolvedValue({
answerText: '',
toolCalls: [
{
function: {
arguments: '{"name":"张三"}'
}
}
],
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
createLLMResponseMock.mockResolvedValue(mockLLMResponse('{"name":"张三"}'));
await dispatchContentExtract(createProps());
const result = await dispatchContentExtract(createProps());
expect(createLLMResponseMock.mock.calls[0][0].body).toMatchObject({
model: 'deepseek-r1',
reasoning_effort: 'none',
toolCallMode: 'toolChoice'
stream: true
});
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', () => {
reasoning: true,
toolChoice: false
});
createLLMResponseMock.mockResolvedValue({
answerText: '{"name":"张三"}',
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
createLLMResponseMock.mockResolvedValue(mockLLMResponse('{"name":"张三"}'));
await dispatchContentExtract(createProps());
......@@ -148,17 +166,86 @@ describe('dispatchContentExtract', () => {
reasoning: false,
toolChoice: false
});
createLLMResponseMock.mockResolvedValue({
answerText: '{"name":"张三"}',
usage: {
inputTokens: 10,
outputTokens: 5,
usedUserOpenAIKey: false
}
});
createLLMResponseMock.mockResolvedValue(mockLLMResponse('{"name":"张三"}'));
await dispatchContentExtract(createProps());
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