Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
赵月辉
/
fastgpt-migrated
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Unverified
Commit
38b26a3b
authored
Jun 15, 2026
by
YeYuheng
Committed by
GitHub
Jun 15, 2026
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Use JSON extraction for content extract node (#7117)
parent
7d5b6a5c
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
206 additions
and
184 deletions
+206
-184
packages/global/core/ai/prompt/agent.ts
+10
-6
packages/global/test/core/ai/prompt/agent.test.ts
+25
-2
packages/service/core/workflow/dispatch/ai/extract.ts
+36
-128
packages/service/test/core/workflow/dispatch/ai/extract.test.ts
+135
-48
No files found.
packages/global/core/ai/prompt/agent.ts
View file @
38b26a3b
...
...
@@ -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
;
};
...
...
packages/global/test/core/ai/prompt/agent.test.ts
View file @
38b26a3b
...
...
@@ -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
(
'最新用户输入优先'
);
});
});
...
...
packages/service/core/workflow/dispatch/ai/extract.ts
View file @
38b26a3b
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
,
...
...
packages/service/test/core/workflow/dispatch/ai/extract.test.ts
View file @
38b26a3b
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
:
''
})
);
});
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment