fix(doubao): submit only verified SSE answers and harden source extraction

Rework the Doubao monitor adapter so success answers come exclusively from
CHUNK_DELTA SSE text and require an SSE_REPLY_END finish event — DOM fallbacks
and FULL_MSG_NOTIFY user echoes can no longer pose as the model reply. Also
auto-switch to the 思考 answer mode, repair mixed-mojibake fragments per run,
unwrap Doubao redirect URLs, scrub "unknown" source metadata, recover from
mid-query navigation by returning unknown for retry, and surface stream/finish
counters in diagnostics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 10:18:53 +08:00
parent 2fd6267632
commit 513e71f9f1
2 changed files with 1202 additions and 73 deletions
@@ -8,6 +8,7 @@ const {
isNonCitationAssetDomain,
isNonCitationAssetUrl,
parseDoubaoStreamBody,
resolveDoubaoSubmissionAnswer,
} = __doubaoTestUtils
describe('doubao adapter helpers', () => {
@@ -59,4 +60,167 @@ describe('doubao adapter helpers', () => {
]),
).toHaveLength(1)
})
it('uses thinking-chain reference metadata to fill unknown Doubao source titles', () => {
const body = [
'event: STREAM_CHUNK',
'data: {"references":[{"url":"https://example.com/report","title":"unkown"}],"thinking":{"references":[{"url":"https://example.com/report#section","title":"思考链路里的真实标题","site_name":"示例站点"}]},"text_block":{"text":"回答"}}',
'',
'',
].join('\n')
const summary = parseDoubaoStreamBody(body)
expect(summary.search_results).toHaveLength(1)
expect(summary.search_results[0]?.url).toBe('https://example.com/report')
expect(summary.search_results[0]?.title).toBe('思考链路里的真实标题')
expect(summary.search_results[0]?.site_name).toBe('示例站点')
})
it('unwraps Doubao redirect URLs before storing citation sources', () => {
const item = buildSourceItem({
url: 'https://www.doubao.com/link?target=https%3A%2F%2Fexample.com%2Fsource%23ref',
title: 'unknown',
})
expect(item).toMatchObject({
url: 'https://example.com/source',
normalized_url: 'https://example.com/source',
host: 'example.com',
title: null,
})
})
it('parses Doubao answer deltas and source cards from the completion SSE stream', () => {
const sourceChunkPayload = {
patch_op: [
{
patch_value: {
content_block: [
{
block_type: 10025,
content: {
search_query_result_block: {
results: [
{
text_card: {
url: 'https://k.sina.cn/article_3244012052_c15bb21400101k0hg.html',
title: '2026年合肥全屋定制品牌综合实力排名出炉',
sitename: '新浪',
logo_url: 'https://p11-spider-image-sign.byteimg.com/logo.jpeg',
},
},
],
},
},
},
],
},
},
],
}
const body = [
'event: SSE_ACK',
'data: {"ack_client_meta":{"conversation_id":"38427686224603138","section_id":"38427686224603394"}}',
'',
'event: FULL_MSG_NOTIFY',
'data: {"message":{"content":"用户问题 echo,不是答案","conversation_id":"38427686224603138"}}',
'',
'event: STREAM_CHUNK',
`data: ${JSON.stringify(sourceChunkPayload)}`,
'',
'event: CHUNK_DELTA',
'data: {"text":"直接给你"}',
'',
'event: CHUNK_DELTA',
'data: {"text":"一份清单。"}',
'',
'event: SSE_REPLY_END',
'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}',
'',
'',
].join('\n')
const summary = parseDoubaoStreamBody(body)
expect(summary.answer).toBe('直接给你一份清单。')
expect(summary.conversation_id).toBe('38427686224603138')
expect(summary.full_message_event_count).toBe(1)
expect(summary.reply_end_event_count).toBe(1)
expect(summary.answer_finish_event_count).toBe(1)
expect(summary.search_results).toHaveLength(1)
expect(summary.search_results[0]).toMatchObject({
url: 'https://k.sina.cn/article_3244012052_c15bb21400101k0hg.html',
title: '2026年合肥全屋定制品牌综合实力排名出炉',
site_name: '新浪',
host: 'k.sina.cn',
})
})
it('repairs mixed Doubao mojibake fragments before returning the SSE answer', () => {
const body = [
'event: CHUNK_DELTA',
'data: {"text":"需要合肥地区全屋定制推荐,"}',
'',
'event: CHUNK_DELTA',
'data: {"text":"综合考量性价比、口碑服务等因素。2000㎡展厅,核验批号。"}',
'',
'event: SSE_REPLY_END',
'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}',
'',
'',
].join('\n')
const summary = parseDoubaoStreamBody(body)
expect(summary.answer).toBe(
'需要合肥地区全屋定制推荐,综合考量性价比、口碑服务等因素。2000㎡展厅,核验批号。',
)
expect(summary.answer).not.toMatch(/[åæèéäãï][\u0080-\u00ff\u02c0-\u02ff\u2000-\u20ff]/)
expect(resolveDoubaoSubmissionAnswer({ answer: summary.answer })).toEqual({
answer:
'需要合肥地区全屋定制推荐,综合考量性价比、口碑服务等因素。2000㎡展厅,核验批号。',
source: 'sse',
})
})
it('does not treat FULL_MSG_NOTIFY user echo as the Doubao answer', () => {
const body = [
'event: FULL_MSG_NOTIFY',
'data: {"message":{"content":"合肥全屋定制哪家好"}}',
'',
'event: SSE_REPLY_END',
'data: {"end_type":2,"answer_finish_attr":{"has_suggest":true}}',
'',
'',
].join('\n')
const summary = parseDoubaoStreamBody(body)
expect(summary.answer).toBeNull()
expect(summary.full_message_event_count).toBe(1)
expect(summary.answer_finish_event_count).toBe(1)
})
it('submits only SSE answer text, without reasoning fallback', () => {
expect(
resolveDoubaoSubmissionAnswer({
answer: 'SSE 正文答案',
}),
).toEqual({
answer: 'SSE 正文答案',
source: 'sse',
})
})
it('does not submit a success answer when the SSE stream has no answer text', () => {
expect(
resolveDoubaoSubmissionAnswer({
answer: null,
}),
).toEqual({
answer: null,
source: null,
})
})
})
File diff suppressed because it is too large Load Diff