feat(yuanbao-adapter): match chat SSE by question and keep answer breaks

Filter captured `/api/chat/*` event-stream responses down to the one
whose request prompt matches the current question, avoiding cross-talk
between concurrent conversations. Preserve blank lines when sanitizing
answer candidates so paragraph breaks survive, and always trust the
parsed SSE answer over the DOM scrape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 14:17:14 +08:00
parent bbc2d89450
commit 70b9f75aea
2 changed files with 221 additions and 69 deletions
@@ -9,11 +9,13 @@ const {
extractCitationMarkerIndexes,
extractQuestionText,
findUnresolvedCitationIndexes,
isCurrentYuanbaoChatSSE,
mergeText,
normalizeCitationMarkers,
normalizeSourceUrl,
parseYuanbaoCaptures,
resolveCitationsFromMarkers,
selectBestAnswerText,
} = __yuanbaoTestUtils
describe('yuanbao adapter helpers', () => {
@@ -67,6 +69,77 @@ describe('yuanbao adapter helpers', () => {
expect(mergeText(first, repeated)).toBe(repeated)
})
it('keeps a valid API SSE answer even when the DOM fallback is longer', () => {
const apiAnswer =
'### 核心结论\n\n混动车兼顾补能效率与低速油耗。\n\n- 适合长途出行\n- 适合没有固定充电桩的用户'
const domFallback =
'页面工具栏 联网搜索 深度思考 已完成搜索。混动车兼顾补能效率与低速油耗,适合长途出行,也适合没有固定充电桩的用户。这里还有页面推荐问题、操作按钮和其他更长的界面文本。'
expect(selectBestAnswerText(apiAnswer, domFallback)).toBe(apiAnswer)
})
it('uses the page RPA answer only when API SSE has no usable answer', () => {
const domFallback = '### 页面兜底\n\n- 仅在 SSE 缺失时使用'
expect(selectBestAnswerText(null, domFallback)).toBe(domFallback)
expect(selectBestAnswerText(' ', domFallback)).toBe(domFallback)
})
it('captures only the current POST chat SSE response', () => {
const question = '今天是几号?'
const requestBody = JSON.stringify({
prompt: question,
supportFunctions: ['autoInternetSearch'],
})
expect(
isCurrentYuanbaoChatSSE(
'https://yuanbao.tencent.com/api/chat/conversation-1',
'POST',
'text/event-stream',
requestBody,
question,
),
).toBe(true)
expect(
isCurrentYuanbaoChatSSE(
'https://yuanbao.tencent.com/api/user/agent/conversation/promptSug',
'POST',
'application/json',
requestBody,
question,
),
).toBe(false)
expect(
isCurrentYuanbaoChatSSE(
'https://yuanbao.tencent.com/api/chat/conversation-1',
'POST',
'text/event-stream',
JSON.stringify({ prompt: '另一个问题' }),
question,
),
).toBe(false)
})
it('builds Markdown only from top-level SSE text frames, excluding deep-search process text', () => {
const summary = parseYuanbaoCaptures([
{
url: 'https://yuanbao.tencent.com/api/chat/conversation-1',
status: 200,
contentType: 'text/event-stream',
body: [
'data: {"type":"deepSearch","contents":[{"type":"text","msg":"搜索过程不应进入最终回答"}]}',
'data: {"type":"text","msg":"### 核心结论\\n\\n"}',
'data: {"type":"text","msg":"正文。\\n\\n- 条目 A\\n- 条目 B"}',
'data: {"type":"meta","endConv":true,"traceId":"trace-1"}',
].join('\n\n'),
},
])
expect(summary.answer).toBe('### 核心结论\n\n正文。\n\n- 条目 A\n- 条目 B')
expect(summary.providerRequestID).toBe('trace-1')
})
it('resolves citation markers against the best available source pool', () => {
const sources = [
{ url: 'https://example.com/1', normalized_url: 'https://example.com/1' },
+148 -69
View File
@@ -350,21 +350,22 @@ function sanitizeAnswerCandidate(value: string | null): string | null {
const repaired = repairPotentialMojibake(normalized) ?? normalized
const collapsed = collapseTokenFragmentRuns(repaired)
const lines = collapsed
.split(/\r?\n+/)
.map((line) => line.trim())
.filter(Boolean)
const lines = collapsed.split(/\r?\n/)
if (lines.length <= 1) {
return collapsed
}
const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line))
if (!keptLines.length) {
const keptLines = lines.filter((line) => !line.trim() || !looksLikeNonAnswerNoise(line.trim()))
if (!keptLines.some((line) => line.trim())) {
return collapsed
}
const cleaned = keptLines.join('\n')
return answerQualityScore(cleaned) >= answerQualityScore(collapsed) ? cleaned : collapsed
return (
keptLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim() || collapsed
)
}
function selectBestAnswerText(
@@ -379,12 +380,7 @@ function selectBestAnswerText(
if (!dom) {
return parsed
}
if (parsed.includes(dom) && answerQualityScore(dom) >= answerQualityScore(parsed) - 24) {
return dom
}
return answerQualityScore(dom) > answerQualityScore(parsed) ? dom : parsed
return parsed
}
function resolveCitationsFromMarkers(
@@ -469,6 +465,16 @@ function getDirectString(record: Record<string, unknown>, keys: string[]): strin
return null
}
function getDirectTextFragment(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = record[key]
if (typeof value === 'string' && value.length > 0) {
return value
}
}
return null
}
function normalizeSourceUrl(value: string | null): string | null {
const input = normalizeText(value)
if (!input) {
@@ -844,7 +850,52 @@ function safePageURL(page: PlaywrightPage): string {
}
}
function attachYuanbaoResponseCapture(page: PlaywrightPage): {
function isCurrentYuanbaoChatSSE(
url: string,
method: string,
contentType: string | null,
requestBody: string | null,
questionText: string,
): boolean {
if (method.toUpperCase() !== 'POST' || !contentType || !/text\/event-stream/i.test(contentType)) {
return false
}
try {
const parsedURL = new URL(url)
if (
parsedURL.hostname !== 'yuanbao.tencent.com' ||
!/^\/api\/chat\/[^/]+\/?$/.test(parsedURL.pathname)
) {
return false
}
} catch {
return false
}
if (!requestBody) {
return false
}
try {
const payload = JSON.parse(requestBody) as unknown
if (!isRecord(payload)) {
return false
}
const expected = normalizeText(questionText)
const candidates = [payload.prompt, payload.displayPrompt]
.map((value) => normalizeText(value))
.filter((value): value is string => value !== null)
return Boolean(expected && candidates.some((candidate) => candidate === expected))
} catch {
return false
}
}
function attachYuanbaoResponseCapture(
page: PlaywrightPage,
questionText: string,
): {
captures: YuanbaoCaptureRecord[]
flush: (timeoutMs: number) => Promise<void>
pendingCount: () => number
@@ -868,17 +919,16 @@ function attachYuanbaoResponseCapture(page: PlaywrightPage): {
const responseHandler = (response: PlaywrightResponse) => {
const url = normalizeOptionalString(response.url())
if (!url || !url.startsWith('https://yuanbao.tencent.com/')) {
if (!url) {
return
}
const headers = response.headers()
const contentType = normalizeOptionalString(headers['content-type']) ?? null
const shouldRead =
url.includes('/api/') ||
response.status() >= 400 ||
(contentType !== null && /(json|event-stream|text\/plain)/i.test(contentType))
if (!shouldRead) {
const request = response.request()
if (
!isCurrentYuanbaoChatSSE(url, request.method(), contentType, request.postData(), questionText)
) {
return
}
@@ -1011,14 +1061,19 @@ function resolveYuanbaoSourceFieldBucket(key: string): 'citation' | 'search' | n
return null
}
function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0): void {
function walkYuanbaoPayload(
value: unknown,
summary: YuanbaoSummary,
depth = 0,
allowAnswer = false,
): void {
if (depth > 12 || value == null) {
return
}
if (Array.isArray(value)) {
for (const item of value) {
walkYuanbaoPayload(item, summary, depth + 1)
walkYuanbaoPayload(item, summary, depth + 1, false)
}
return
}
@@ -1045,10 +1100,12 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0):
summary.contentTypes.add(type)
switch (type) {
case 'text':
summary.answer = mergeText(
summary.answer,
getDirectString(value, ['msg', 'text', 'content']),
)
if (allowAnswer) {
summary.answer = mergeText(
summary.answer,
getDirectTextFragment(value, ['msg', 'text', 'content']),
)
}
break
case 'think':
summary.reasoning = mergeText(
@@ -1080,7 +1137,7 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0):
collectSourceBucket(value.thinkDeepSections, summary.citations)
break
case 'deepSearch':
walkYuanbaoPayload(value.contents, summary, depth + 1)
walkYuanbaoPayload(value.contents, summary, depth + 1, false)
break
case 'step':
collectSourceBucket(value.thinkDeepSections, summary.citations)
@@ -1099,36 +1156,36 @@ function walkYuanbaoPayload(value: unknown, summary: YuanbaoSummary, depth = 0):
}
if (Array.isArray(value.content)) {
walkYuanbaoPayload(value.content, summary, depth + 1)
walkYuanbaoPayload(value.content, summary, depth + 1, false)
}
if (Array.isArray(value.contents)) {
walkYuanbaoPayload(value.contents, summary, depth + 1)
walkYuanbaoPayload(value.contents, summary, depth + 1, false)
}
if (Array.isArray(value.speechesV2)) {
walkYuanbaoPayload(value.speechesV2, summary, depth + 1)
walkYuanbaoPayload(value.speechesV2, summary, depth + 1, false)
}
if (Array.isArray(value.convs)) {
walkYuanbaoPayload(value.convs, summary, depth + 1)
walkYuanbaoPayload(value.convs, summary, depth + 1, false)
}
if (Array.isArray(value.docs)) {
walkYuanbaoPayload(value.docs, summary, depth + 1)
walkYuanbaoPayload(value.docs, summary, depth + 1, false)
}
if (Array.isArray(value.citations)) {
walkYuanbaoPayload(value.citations, summary, depth + 1)
walkYuanbaoPayload(value.citations, summary, depth + 1, false)
}
if (Array.isArray(value.ref_docs)) {
walkYuanbaoPayload(value.ref_docs, summary, depth + 1)
walkYuanbaoPayload(value.ref_docs, summary, depth + 1, false)
}
if (Array.isArray(value.thinkDeepSections)) {
walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1)
walkYuanbaoPayload(value.thinkDeepSections, summary, depth + 1, false)
}
if (Array.isArray(value.list)) {
walkYuanbaoPayload(value.list, summary, depth + 1)
walkYuanbaoPayload(value.list, summary, depth + 1, false)
}
for (const nested of Object.values(value)) {
if (Array.isArray(nested) || isRecord(nested)) {
walkYuanbaoPayload(nested, summary, depth + 1)
walkYuanbaoPayload(nested, summary, depth + 1, false)
}
}
}
@@ -1155,7 +1212,7 @@ function parseYuanbaoCaptures(captures: YuanbaoCaptureRecord[]): YuanbaoSummary
const candidates = extractJSONCandidates(capture.body)
summary.candidateCount += candidates.length
for (const candidate of candidates) {
walkYuanbaoPayload(candidate, summary)
walkYuanbaoPayload(candidate, summary, 0, true)
}
}
@@ -1551,7 +1608,7 @@ const yuanbaoQueryInPage = async (parameters: {
for (const scope of scopes) {
const nodes = Array.from(
scope.querySelectorAll(
"button, [role='button'], label, [tabindex], [dt-button-id], [data-button-id]",
"button, [role='button'], [role='menuitem'], [role='option'], li.t-dropdown__item, label, [tabindex], [dt-button-id], [data-button-id]",
),
)
for (const node of nodes) {
@@ -1585,7 +1642,29 @@ const yuanbaoQueryInPage = async (parameters: {
await wait(delayMs)
}
const hasActiveComposerMode = (pattern: RegExp, shell: HTMLElement | null): boolean => {
const scopes: ParentNode[] = shell ? [shell, document] : [document]
const seen = new Set<Element>()
for (const scope of scopes) {
const atoms = Array.from(scope.querySelectorAll('.application-blot-ai-atom'))
for (const atom of atoms) {
if (seen.has(atom) || !isVisible(atom)) {
continue
}
seen.add(atom)
const text = normalize(atom.textContent)
if (text && pattern.test(text)) {
return true
}
}
}
return false
}
const readToggleStateByPattern = (pattern: RegExp, shell: HTMLElement | null): boolean | null => {
if (hasActiveComposerMode(pattern, shell)) {
return true
}
const target = findInteractiveByText(pattern, shell)
return target ? toggleState(target) : null
}
@@ -1611,29 +1690,23 @@ const yuanbaoQueryInPage = async (parameters: {
}
const ensureWebSearchEnabled = async (shell: HTMLElement | null): Promise<boolean | null> => {
const directState = await ensureToggleEnabledByPattern(webSearchPattern, shell)
if (directState === true) {
if (hasActiveComposerMode(webSearchPattern, shell)) {
return true
}
const toolsButton = findInteractiveByText(toolsPattern, shell)
if (!toolsButton) {
return directState
return null
}
await clickInteractive(toolsButton, 220)
const menuItem = findInteractiveByText(webSearchPattern, null)
if (!menuItem) {
return directState
return false
}
let menuState = toggleState(menuItem)
if (menuState !== true) {
await clickInteractive(menuItem, 220)
menuState = toggleState(menuItem)
}
return menuState ?? true
await clickInteractive(menuItem, 300)
return hasActiveComposerMode(webSearchPattern, shell)
}
const setComposerText = (composer: HTMLElement, text: string) => {
@@ -2265,6 +2338,9 @@ const yuanbaoQueryInPage = async (parameters: {
if (enableWebSearch) {
lastKnownWebSearchEnabled = await ensureWebSearchEnabled(composerShell)
if (lastKnownWebSearchEnabled !== true) {
return fail('yuanbao_web_search_enable_failed', bodyText(), composerTop)
}
}
setComposerText(composer, questionText)
@@ -2349,7 +2425,7 @@ export const yuanbaoAdapter: MonitorAdapter = {
context.reportProgress('yuanbao.page_ready')
await ensurePageOnYuanbaoChat(page, context.signal)
const capture = attachYuanbaoResponseCapture(page)
const capture = attachYuanbaoResponseCapture(page, questionText)
let pageResult: YuanbaoPageQueryResult
try {
@@ -2370,15 +2446,13 @@ export const yuanbaoAdapter: MonitorAdapter = {
const parsed = parseYuanbaoCaptures(capture.captures)
const pendingCaptureCount = capture.pendingCount()
const domCitations = buildOrderedDomSourceItems(pageResult.domLinks)
const searchResults = dedupeSourceItems(parsed.searchResults)
let answerWithMarkers = selectBestAnswerText(parsed.answer, pageResult.domAnswer)
if (
pageResult.inlineCitationIndexes.length > 0 &&
extractCitationMarkerIndexes(answerWithMarkers).length === 0 &&
extractCitationMarkerIndexes(pageResult.domAnswer).length > 0
) {
answerWithMarkers = pageResult.domAnswer
}
const searchResults = parsed.searchResults.length
? dedupeSourceItems(parsed.searchResults)
: dedupeSourceItems(domCitations)
const apiAnswer = sanitizeAnswerCandidate(parsed.answer)
const domAnswer = sanitizeAnswerCandidate(pageResult.domAnswer)
const answerWithMarkers = selectBestAnswerText(apiAnswer, domAnswer)
const responseSource = apiAnswer ? 'api_sse' : domAnswer ? 'page_rpa' : null
const citationMarkerIndexes = extractCitationMarkerIndexes(
answerWithMarkers ?? pageResult.domAnswer,
)
@@ -2387,20 +2461,22 @@ export const yuanbaoAdapter: MonitorAdapter = {
: 0
const markerCitations = dedupeSourceItems(
resolveCitationsFromMarkers(answerWithMarkers ?? pageResult.domAnswer, [
domCitations,
parsed.citations,
parsed.searchResults,
dedupeSourceItems([...domCitations, ...parsed.citations, ...parsed.searchResults]),
domCitations,
dedupeSourceItems([...parsed.citations, ...parsed.searchResults, ...domCitations]),
]),
)
const answer = normalizeCitationMarkers(answerWithMarkers)
const citations =
domCitations.length >= maxCitationMarkerIndex
? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations])
: appendUniqueSourceItems(
dedupeSourceItems([...parsed.citations, ...markerCitations]),
domCitations,
)
parsed.citations.length >= maxCitationMarkerIndex
? appendUniqueSourceItems(parsed.citations, [...markerCitations, ...domCitations])
: domCitations.length >= maxCitationMarkerIndex
? appendUniqueSourceItems(domCitations, [...parsed.citations, ...markerCitations])
: appendUniqueSourceItems(
dedupeSourceItems([...parsed.citations, ...markerCitations]),
domCitations,
)
const inlineCitationCandidates = dedupeSourceItems(
citationMarkerIndexes
.map((index) => citations[index - 1] ?? null)
@@ -2482,6 +2558,7 @@ export const yuanbaoAdapter: MonitorAdapter = {
request_id: requestID,
conversation_id: conversationID,
answer,
response_source: responseSource,
reasoning,
citation_count: citations.length,
search_result_count: searchResults.length,
@@ -2494,6 +2571,7 @@ export const yuanbaoAdapter: MonitorAdapter = {
model_label: pageResult.modelLabel,
deep_think_enabled: pageResult.deepThinkEnabled,
web_search_enabled: pageResult.webSearchEnabled,
response_source: responseSource,
candidate_count: parsed.candidateCount,
capture_count: capture.captures.length,
capture_pending_after_flush: pendingCaptureCount,
@@ -2518,6 +2596,7 @@ export const __yuanbaoTestUtils = {
extractCitationMarkerIndexes,
extractQuestionText,
findUnresolvedCitationIndexes,
isCurrentYuanbaoChatSSE,
mergeText,
normalizeCitationMarkers,
normalizeSourceUrl,