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:
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user