fix(yuanbao): track new hunyuan_t1 SSE protocol and align inline citations with DeepSeek

- findInteractiveByText now also accepts elements carrying business
  attributes [dt-button-id]/[data-button-id]. The new yuanbao toolbar
  renders the deep-think toggle as <div dt-button-id="deep_think"> rather
  than a <button>, so the previous text-only locator missed it and probes
  silently ran in the fast model — without inline citations.
- Lower <div data-idx-list="1,2,10"> placeholders to bare "[1][2][10]"
  (previously "[citation:1] [citation:2] [citation:10]") and accept both
  forms in YUANBAO_CITATION_MARKER_PATTERN. Final answer text runs through
  normalizeCitationMarkers so any residual SSE [citation:N] is rewritten
  to [N], matching DeepSeek's chip rendering on the SaaS side.
- searchGuid frames in the new protocol carry every reference inside
  docs[] (each with a 1-based index that mirrors the answer markers) and
  citations is now always null. Promote docs into the citations bucket so
  citations[index-1] aligns with the inline marker, eliminating the
  spurious yuanbao_incomplete_citations failures.
- Update the SaaS detail view: createYuanbaoCitationPattern accepts both
  "[N]" and "[citation:N]" so yuanbao answers render the same clickable
  superscript chips as DeepSeek.

Tests cover the new searchGuid.docs alignment, fast-cache answers (no
markers, no sources -> still succeed), legacy [citation:N] -> [N]
rewriting, and mixed-format marker extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:51:24 +08:00
parent af35301d9b
commit 1e6ed77453
3 changed files with 173 additions and 6 deletions
@@ -10,6 +10,7 @@ const {
extractQuestionText,
findUnresolvedCitationIndexes,
mergeText,
normalizeCitationMarkers,
normalizeSourceUrl,
parseYuanbaoCaptures,
resolveCitationsFromMarkers,
@@ -129,6 +130,124 @@ describe('yuanbao adapter helpers', () => {
expect(findUnresolvedCitationIndexes('正文没有引用小图标', [])).toEqual([])
})
it("recognises both '[N]' and '[citation:N]' inline markers", () => {
// The new yuanbao model lowers <div data-idx-list="1,2,10"> placeholders to "[1][2][10]";
// older SSE frames may still emit "[citation:N]". Both must extract the same indexes.
expect(extractCitationMarkerIndexes('合肥老牌选 [1][2] 性价比 [10]。')).toEqual([1, 2, 10])
expect(
extractCitationMarkerIndexes('混合两种格式 [citation:3] 还有 [4]'),
).toEqual([3, 4])
const sources = [
{ url: 'https://example.com/1', normalized_url: 'https://example.com/1' },
{ url: 'https://example.com/2', normalized_url: 'https://example.com/2' },
]
expect(
resolveCitationsFromMarkers('正文 [1] 然后 [2]', [[], sources]).map((item) => item.url),
).toEqual(['https://example.com/1', 'https://example.com/2'])
})
it('rewrites legacy [citation:N] markers to bare [N] for SaaS rendering', () => {
expect(normalizeCitationMarkers('正文 [citation:1] 和 [citation:10]')).toBe(
'正文 [1] 和 [10]',
)
// Already-normalised text passes through.
expect(normalizeCitationMarkers('已经是 [1][2] 格式')).toBe('已经是 [1][2] 格式')
// Null/empty pass through.
expect(normalizeCitationMarkers(null)).toBeNull()
expect(normalizeCitationMarkers('')).toBe('')
})
it("does not invent citations for fast-cache answers (no searchGuid frame, no [N] markers)", () => {
// hunyuan_t1 sometimes returns a cached / non-search answer ("已深度思考(用时1秒)"
// with no "找到了 N 篇相关资料" panel). The SSE then carries only `text`
// frames and no `searchGuid`. The adapter must still surface that answer to
// the SaaS pipeline — see the top-level query path: as long as `answer` is
// non-empty and there are no [N] markers, neither the empty-response nor
// the incomplete-citations branches fire, so we fall through to status=succeeded.
const sseFrames = [
JSON.stringify({ type: 'text', msg: '在合肥,' }),
JSON.stringify({ type: 'text', msg: '全屋定制市场非常成熟,' }),
JSON.stringify({ type: 'text', msg: '没有绝对"最好"的商家。' }),
]
.map((line) => `data: ${line}`)
.join('\n\n')
const summary = parseYuanbaoCaptures([
{
url: 'https://yuanbao.tencent.com/api/chat/cache-hit',
status: 200,
contentType: 'text/event-stream',
body: sseFrames,
},
])
expect(summary.answer).toBe('在合肥,全屋定制市场非常成熟,没有绝对"最好"的商家。')
expect(summary.citations).toEqual([])
expect(summary.searchResults).toEqual([])
// No marker → no unresolved indexes, so the adapter does not gate on citations.
expect(extractCitationMarkerIndexes(summary.answer)).toEqual([])
expect(findUnresolvedCitationIndexes(summary.answer, summary.citations)).toEqual([])
})
it("aligns citations[index-1] with answer markers when SSE places refs in searchGuid.docs (new hunyuan_t1 protocol)", () => {
// Captured against the live yuanbao endpoint: deep-search now ships every
// reference inside `searchGuid.docs[]` with a 1-based `index`, and the
// legacy `citations` field is always null in this frame. Whatever the
// adapter normalises to must preserve idx alignment so that the
// unresolved-citations check passes.
const sseFrames = [
JSON.stringify({
type: 'searchGuid',
title: '引用 3 篇资料作为参考',
docs: [
{
index: 1,
title: '志邦家居官网',
url: 'https://www.zbom.com/about',
web_url: 'https://www.zbom.com/about',
web_site_name: '志邦家居',
},
{
index: 2,
title: '合肥本地装修评测',
url: 'https://example.com/hefei-review',
web_site_name: '今日头条',
},
{
index: 3,
title: '客来福官方介绍',
url: 'https://example.com/kelaifu',
web_site_name: '客来福',
},
],
citations: null,
}),
JSON.stringify({
type: 'text',
msg: '志邦家居 [1] 与客来福 [3] 都是合肥本土头部 [2]。',
}),
]
.map((line) => `data: ${line}`)
.join('\n\n')
const summary = parseYuanbaoCaptures([
{
url: 'https://yuanbao.tencent.com/api/chat/abc',
status: 200,
contentType: 'text/event-stream',
body: sseFrames,
},
])
expect(summary.citations.map((item) => item.url)).toEqual([
'https://www.zbom.com/about',
'https://example.com/hefei-review',
'https://example.com/kelaifu',
])
expect(findUnresolvedCitationIndexes(summary.answer, summary.citations)).toEqual([])
})
it('collects source candidates from deep search thinking fields', () => {
const summary = parseYuanbaoCaptures([
{