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:
@@ -321,10 +321,22 @@ function createQwenSourceGroupPattern(): RegExp {
|
||||
}
|
||||
|
||||
function createYuanbaoCitationPattern(): RegExp {
|
||||
return /\[citation:\s*(\d+)\]/gi
|
||||
// Match both formats so the same renderer works across the migration:
|
||||
// - "[citation:N]" — legacy yuanbao SSE protocol.
|
||||
// - "[N]" — new hunyuan_t1 protocol; the desktop adapter now lowers
|
||||
// `<div data-idx-list="1,2,10">` placeholders to bare "[N]" and also
|
||||
// normalises any residual "[citation:N]" SSE frames to bare "[N]" so
|
||||
// yuanbao answers render with the same chip-style superscript as
|
||||
// DeepSeek.
|
||||
return /\[(?:citation:\s*)?(\d+)\]/gi
|
||||
}
|
||||
|
||||
function createYuanbaoCitationStripPattern(): RegExp {
|
||||
// The pattern's `replace` step covers any `[N]` we successfully matched.
|
||||
// Strip leftover legacy `[citation:...]` fragments only — we must not
|
||||
// strip bare "[N]" tokens here, because formatAnswerContent runs strip
|
||||
// *after* replacing matched markers, so any remaining "[N]" is incidental
|
||||
// text that should stay untouched.
|
||||
return /\[\s*citation\s*:[^\[\]]*?\]/gi
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -10,7 +10,14 @@ const YUANBAO_QUERY_TIMEOUT_MS = 120_000
|
||||
const YUANBAO_CAPTURE_LIMIT = 96
|
||||
const YUANBAO_CAPTURE_BODY_LIMIT = 1_200_000
|
||||
const YUANBAO_CAPTURE_FLUSH_TIMEOUT_MS = 20_000
|
||||
const YUANBAO_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi
|
||||
// Match both formats so we tolerate either side of the migration without breaking:
|
||||
// - "[citation:N]" — emitted by the legacy yuanbao SSE protocol and what older
|
||||
// captures / fixtures still contain.
|
||||
// - "[N]" — emitted by the new model (DOM places <div data-idx-list="N,M,..."/>
|
||||
// placeholders inline; we now lower them to bare "[N]" markers, which is also
|
||||
// the format DeepSeek answers carry through to the SaaS side).
|
||||
const YUANBAO_CITATION_MARKER_PATTERN = /\[(?:citation:\s*)?(\d+)\]/gi
|
||||
const YUANBAO_LEGACY_CITATION_MARKER_PATTERN = /\[citation:\s*(\d+)\]/gi
|
||||
const WINDOWS_1252_REVERSE_MAP = new Map<number, number>([
|
||||
[0x20ac, 0x80],
|
||||
[0x201a, 0x82],
|
||||
@@ -205,6 +212,16 @@ function extractCitationMarkerIndexes(value: string | null): number[] {
|
||||
return indexes
|
||||
}
|
||||
|
||||
// Normalise legacy "[citation:N]" markers (still emitted by some SSE frames)
|
||||
// to "[N]" so the SaaS front-end can render the same superscript chip as
|
||||
// DeepSeek answers — without that, mixed sources show two unrelated formats.
|
||||
function normalizeCitationMarkers(value: string | null): string | null {
|
||||
if (value == null) {
|
||||
return value
|
||||
}
|
||||
return value.replace(YUANBAO_LEGACY_CITATION_MARKER_PATTERN, '[$1]')
|
||||
}
|
||||
|
||||
function looksLikeNonAnswerNoise(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
@@ -1041,6 +1058,14 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0):
|
||||
)
|
||||
break
|
||||
case 'searchGuid':
|
||||
// The new hunyuan_t1 deep-search SSE places EVERY citable reference in
|
||||
// `docs[]` (each entry carries a 1-based `index` that aligns with the
|
||||
// answer's `[N]` markers), and the legacy `citations` field is now
|
||||
// always `null`. Promote `docs` into both buckets — the citations
|
||||
// bucket preserves array order so `citations[index - 1]` matches the
|
||||
// marker, while the search-results bucket keeps the existing
|
||||
// `dom_reference_links` semantics for downstream consumers.
|
||||
collectSourceBucket(value.docs, summary.citations)
|
||||
collectSourceBucket(value.docs, summary.searchResults)
|
||||
collectSourceBucket(value.citations, summary.citations)
|
||||
break
|
||||
@@ -1518,8 +1543,18 @@ const yuanbaoQueryInPage = async (parameters: {
|
||||
const seen = new Set<HTMLElement>()
|
||||
const candidates: Array<{ element: HTMLElement; score: number }> = []
|
||||
|
||||
// The new yuanbao toolbar renders the deep-think toggle as a plain <div>
|
||||
// with the business attribute `dt-button-id="deep_think"` (and parallel
|
||||
// `dt-button-id` / `data-button-id` markers on other toolbar buttons),
|
||||
// not a <button>/[role=button]. Include those selectors so we still
|
||||
// match the toggle by visible text. Business attributes are far more
|
||||
// stable than CSS-module-hashed class names.
|
||||
for (const scope of scopes) {
|
||||
const nodes = Array.from(scope.querySelectorAll("button, [role='button'], label, [tabindex]"))
|
||||
const nodes = Array.from(
|
||||
scope.querySelectorAll(
|
||||
"button, [role='button'], label, [tabindex], [dt-button-id], [data-button-id]",
|
||||
),
|
||||
)
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement) || seen.has(node) || !isVisible(node)) {
|
||||
continue
|
||||
@@ -1912,8 +1947,8 @@ const yuanbaoQueryInPage = async (parameters: {
|
||||
.split(/[,,\s]+/)
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.filter((index) => Number.isFinite(index) && index > 0)
|
||||
.map((index) => `[citation:${index}]`)
|
||||
.join(' ')
|
||||
.map((index) => `[${index}]`)
|
||||
.join('')
|
||||
if (!markers) {
|
||||
continue
|
||||
}
|
||||
@@ -2359,7 +2394,7 @@ export const yuanbaoAdapter: MonitorAdapter = {
|
||||
dedupeSourceItems([...domCitations, ...parsed.citations, ...parsed.searchResults]),
|
||||
]),
|
||||
)
|
||||
const answer = answerWithMarkers
|
||||
const answer = normalizeCitationMarkers(answerWithMarkers)
|
||||
const citations =
|
||||
domCitations.length >= maxCitationMarkerIndex
|
||||
? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations])
|
||||
@@ -2485,6 +2520,7 @@ export const __yuanbaoTestUtils = {
|
||||
extractQuestionText,
|
||||
findUnresolvedCitationIndexes,
|
||||
mergeText,
|
||||
normalizeCitationMarkers,
|
||||
normalizeSourceUrl,
|
||||
parseYuanbaoCaptures,
|
||||
resolveCitationsFromMarkers,
|
||||
|
||||
Reference in New Issue
Block a user