feat(qwen): implement source fallback mechanism and add tests for settled page sources
Desktop Client Build / Resolve Build Metadata (push) Successful in 36s
Desktop Client Build / Build Desktop Client (push) Successful in 23m42s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 33s

This commit is contained in:
2026-07-14 10:51:15 +08:00
parent 75c9be5eb3
commit b0bf9b4299
2 changed files with 333 additions and 13 deletions
@@ -1,9 +1,18 @@
import { describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AdapterContext } from './base' import type { AdapterContext } from './base'
import { __qwenTestUtils, qwenAdapter } from './qwen' import { __qwenTestUtils, qwenAdapter } from './qwen'
const { extractQuestionText, parseQwenSSEResponse, resolveQwenAnswerText } = __qwenTestUtils const {
extractQuestionText,
parseQwenSSEResponse,
qwenCollectSettledPageSources,
resolveQwenAnswerText,
} = __qwenTestUtils
afterEach(() => {
vi.unstubAllGlobals()
})
function buildSSEBody(messages: Record<string, unknown>[]): string { function buildSSEBody(messages: Record<string, unknown>[]): string {
return messages return messages
@@ -66,6 +75,77 @@ describe('qwen official SSE response parsing', () => {
}) })
}) })
describe('qwen settled page source fallback', () => {
it('extracts the reference source payload rendered by the Mac ARM page state', async () => {
vi.stubGlobal('window', {
setTimeout,
__qianwenChatAPI: {
sharedValues: {
chatAPI: {
state: {
chatRounds: [
{
currentQuestionIndex: 0,
questions: [
{
currentAnswerIndex: 0,
cards: [{ content: '合肥全屋定制公司推荐' }],
answers: [
{
content: {
cards: [
{
metaData: {
sources: [
{
content: {
list: [
{
url: 'https://example.com/reference',
title: '真实引用来源',
name: '示例站',
},
],
},
},
],
},
},
],
},
},
],
},
],
},
],
},
},
},
},
})
const result = await qwenCollectSettledPageSources({
questionText: '合肥全屋定制公司推荐',
timeoutMs: 0,
})
expect(result.sourcePayloads).toEqual([
{
content: {
list: [
{
url: 'https://example.com/reference',
title: '真实引用来源',
name: '示例站',
},
],
},
},
])
})
})
describe('qwen adapter query result validation', () => { describe('qwen adapter query result validation', () => {
it('expands an internal tab-container reference into the selected answer', async () => { it('expands an internal tab-container reference into the selected answer', async () => {
const page = { const page = {
@@ -254,8 +334,78 @@ describe('qwen adapter query result validation', () => {
expect(result.status).toBe('succeeded') expect(result.status).toBe('succeeded')
expect(result.payload?.answer).toBe('官方 SSE 完整答案') expect(result.payload?.answer).toBe('官方 SSE 完整答案')
expect( expect((result.payload?.raw_response_json as Record<string, unknown>).response_source).toBe(
(result.payload?.raw_response_json as Record<string, unknown>).response_source, 'api_sse',
).toBe('api_sse') )
})
it('falls back to the settled page state when sources arrive after answer finish', async () => {
let evaluateCallCount = 0
const page = {
waitForLoadState: async () => undefined,
on: () => undefined,
off: () => undefined,
url: () => 'https://www.qianwen.com/chat/demo',
goto: async () => undefined,
evaluate: async () => {
evaluateCallCount += 1
if (evaluateCallCount === 1) {
return {
ok: true as const,
url: 'https://www.qianwen.com/chat/demo',
question: {
model: 'Qwen',
deepSearch: '0',
enableSearch: null,
cardText: '测试问题',
},
answer: {
reqId: 'req-delayed-sources',
status: 'finish',
content: { barIframe: { content: '页面先完成回答。' } },
extraInfo: null,
communication: null,
},
}
}
return {
elapsedMs: 350,
sourcePayloads: [
{
content: {
list: [
{
url: 'https://example.com/settled-source',
title: '稍后到达的引用来源',
name: '示例站',
},
],
},
},
],
}
},
}
const context = {
playwright: { page, browser: {} },
reportProgress: () => undefined,
} as unknown as AdapterContext
const result = await qwenAdapter.query(context, { question_text: '测试问题' })
expect(result.status).toBe('succeeded')
expect(result.payload?.search_results).toEqual([
expect.objectContaining({
url: 'https://example.com/settled-source',
title: '稍后到达的引用来源',
site_name: '示例站',
}),
])
expect((result.payload?.raw_response_json as Record<string, unknown>).source_fallback).toEqual({
used: true,
elapsed_ms: 350,
payload_count: 1,
})
}) })
}) })
+178 -8
View File
@@ -7,6 +7,7 @@ import { normalizeText } from './common'
const QWEN_BOOTSTRAP_URL = 'https://www.qianwen.com/' const QWEN_BOOTSTRAP_URL = 'https://www.qianwen.com/'
const QWEN_PAGE_READY_TIMEOUT_MS = 20_000 const QWEN_PAGE_READY_TIMEOUT_MS = 20_000
const QWEN_QUERY_TIMEOUT_MS = 90_000 const QWEN_QUERY_TIMEOUT_MS = 90_000
const QWEN_SOURCE_FALLBACK_TIMEOUT_MS = 5_000
const QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN = /^(?:\[\(tab_container_\d+\)\]\s*)+$/i const QWEN_INTERNAL_TAB_PLACEHOLDER_PATTERN = /^(?:\[\(tab_container_\d+\)\]\s*)+$/i
const QWEN_INTERNAL_TAB_REFERENCE_PATTERN = /\[\(tab_container_\d+\)\]/i const QWEN_INTERNAL_TAB_REFERENCE_PATTERN = /\[\(tab_container_\d+\)\]/i
@@ -50,6 +51,11 @@ type QwenParsedSSEResponse = {
events: Record<string, unknown>[] events: Record<string, unknown>[]
} }
type QwenPageSourceFallbackResult = {
elapsedMs: number
sourcePayloads: unknown[]
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
@@ -220,9 +226,7 @@ function resolveQwenTabContainerText(card: unknown): string | null {
return null return null
} }
const tabs = Array.isArray(card.content.tabs) const tabs = Array.isArray(card.content.tabs) ? card.content.tabs.filter(isRecord) : []
? card.content.tabs.filter(isRecord)
: []
const selectedTabId = const selectedTabId =
normalizeOptionalString(card.content.selected_tab) ?? normalizeOptionalString(card.content.selected_tab) ??
normalizeOptionalString(card.content.default_tab) normalizeOptionalString(card.content.default_tab)
@@ -384,10 +388,7 @@ function parseQwenSSEResponse(body: string): QwenParsedSSEResponse {
for (const payload of payloads) { for (const payload of payloads) {
const data = isRecord(payload.data) ? payload.data : payload const data = isRecord(payload.data) ? payload.data : payload
extraInfo = mergeQwenSSERecord(extraInfo, data.extra_info ?? data.extraInfo) extraInfo = mergeQwenSSERecord(extraInfo, data.extra_info ?? data.extraInfo)
communication = mergeQwenSSERecord( communication = mergeQwenSSERecord(communication, data.communication ?? data.communication_info)
communication,
data.communication ?? data.communication_info,
)
} }
const content: Record<string, unknown> = {} const content: Record<string, unknown> = {}
@@ -810,6 +811,150 @@ function qwenQueryInPage(parameters: {
})() })()
} }
function qwenCollectSettledPageSources(parameters: {
questionText: string
timeoutMs: number
}): Promise<QwenPageSourceFallbackResult> {
const startedAt = Date.now()
const wait = (timeMs: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, timeMs)
})
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value)
const normalizeInlineText = (value: unknown): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed ? trimmed : null
}
const toSerializable = (value: unknown): unknown[] => {
try {
const serialized = JSON.parse(JSON.stringify(value)) as unknown
return Array.isArray(serialized) ? serialized : []
} catch {
return []
}
}
const findLatestAnswerContent = (): Record<string, unknown> | null => {
const state = (
window as typeof window & {
__qianwenChatAPI?: {
sharedValues?: {
chatAPI?: {
state?: {
chatRounds?: Array<Record<string, unknown>>
}
}
}
}
}
).__qianwenChatAPI?.sharedValues?.chatAPI?.state
const chatRounds = Array.isArray(state?.chatRounds) ? state.chatRounds : []
for (let roundIndex = chatRounds.length - 1; roundIndex >= 0; roundIndex -= 1) {
const round = chatRounds[roundIndex]
if (!isObjectRecord(round)) {
continue
}
const questions = Array.isArray(round.questions) ? round.questions : []
const currentQuestionIndex =
typeof round.currentQuestionIndex === 'number' ? round.currentQuestionIndex : 0
const question = questions[currentQuestionIndex]
if (!isObjectRecord(question)) {
continue
}
const cards = Array.isArray(question.cards) ? question.cards : []
const firstCard = cards[0]
const cardText =
isObjectRecord(firstCard) && 'content' in firstCard
? normalizeInlineText(firstCard.content)
: null
if (cardText !== parameters.questionText) {
continue
}
const answers = Array.isArray(question.answers) ? question.answers : []
const currentAnswerIndex =
typeof question.currentAnswerIndex === 'number' ? question.currentAnswerIndex : 0
const answer = answers[currentAnswerIndex]
return isObjectRecord(answer) && isObjectRecord(answer.content) ? answer.content : null
}
return null
}
const collectSourcePayloads = (payload: unknown): unknown[] => {
const result: unknown[] = []
const visit = (value: unknown, depth: number) => {
if (depth > 12 || value == null) {
return
}
if (Array.isArray(value)) {
for (const item of value) {
visit(item, depth + 1)
}
return
}
if (!isObjectRecord(value)) {
return
}
for (const [key, child] of Object.entries(value)) {
if (key.toLowerCase() === 'sources' && Array.isArray(child)) {
result.push(...child)
continue
}
if (Array.isArray(child) || isObjectRecord(child)) {
visit(child, depth + 1)
}
}
}
visit(payload, 0)
return toSerializable(result)
}
const containsSourceURL = (payload: unknown, depth = 0): boolean => {
if (depth > 12 || payload == null) {
return false
}
if (Array.isArray(payload)) {
return payload.some((item) => containsSourceURL(item, depth + 1))
}
if (!isObjectRecord(payload)) {
return false
}
const directURL = normalizeInlineText(payload.url ?? payload.href ?? payload.link)
if (directURL) {
return true
}
return Object.values(payload).some((value) => containsSourceURL(value, depth + 1))
}
return (async () => {
const deadline = startedAt + parameters.timeoutMs
let latestSourcePayloads: unknown[] = []
while (Date.now() <= deadline) {
latestSourcePayloads = collectSourcePayloads(findLatestAnswerContent())
if (containsSourceURL(latestSourcePayloads)) {
break
}
await wait(250)
}
return {
elapsedMs: Date.now() - startedAt,
sourcePayloads: latestSourcePayloads,
}
})()
}
export const qwenAdapter: MonitorAdapter = { export const qwenAdapter: MonitorAdapter = {
provider: 'qwen', provider: 'qwen',
executionMode: 'playwright', executionMode: 'playwright',
@@ -890,7 +1035,26 @@ export const qwenAdapter: MonitorAdapter = {
const searchResults: MonitoringSourceItem[] = [] const searchResults: MonitoringSourceItem[] = []
collectQwenSources(apiResult?.events, searchResults) collectQwenSources(apiResult?.events, searchResults)
collectQwenSources(pageResult.answer.content, searchResults) collectQwenSources(pageResult.answer.content, searchResults)
const dedupedSearchResults = dedupeSourceItems(searchResults) let dedupedSearchResults = dedupeSourceItems(searchResults)
let sourceFallbackAttempted = false
let sourceFallbackResult: QwenPageSourceFallbackResult | null = null
let sourceFallbackPayloads: unknown[] = []
if (answerText && !dedupedSearchResults.length) {
sourceFallbackAttempted = true
context.reportProgress('qwen.sources_fallback')
sourceFallbackResult = await page
.evaluate(qwenCollectSettledPageSources, {
questionText,
timeoutMs: QWEN_SOURCE_FALLBACK_TIMEOUT_MS,
})
.catch(() => null)
sourceFallbackPayloads = Array.isArray(sourceFallbackResult?.sourcePayloads)
? sourceFallbackResult.sourcePayloads
: []
collectQwenSources(sourceFallbackPayloads, searchResults)
dedupedSearchResults = dedupeSourceItems(searchResults)
}
if (!answerText && unresolvedPlaceholder) { if (!answerText && unresolvedPlaceholder) {
return { return {
@@ -941,6 +1105,11 @@ export const qwenAdapter: MonitorAdapter = {
question: toJsonValue(pageResult.question), question: toJsonValue(pageResult.question),
source_count: dedupedSearchResults.length, source_count: dedupedSearchResults.length,
search_results: toJsonSources(dedupedSearchResults), search_results: toJsonSources(dedupedSearchResults),
source_fallback: {
used: sourceFallbackAttempted,
elapsed_ms: sourceFallbackResult?.elapsedMs ?? null,
payload_count: sourceFallbackPayloads.length,
},
content: toJsonValue(apiResult?.content ?? pageResult.answer.content), content: toJsonValue(apiResult?.content ?? pageResult.answer.content),
extra_info: toJsonValue(apiResult?.extraInfo ?? pageResult.answer.extraInfo), extra_info: toJsonValue(apiResult?.extraInfo ?? pageResult.answer.extraInfo),
communication: toJsonValue(apiResult?.communication ?? pageResult.answer.communication), communication: toJsonValue(apiResult?.communication ?? pageResult.answer.communication),
@@ -956,4 +1125,5 @@ export const __qwenTestUtils = {
isQwenInternalPlaceholderOnly, isQwenInternalPlaceholderOnly,
resolveQwenAnswerText, resolveQwenAnswerText,
parseQwenSSEResponse, parseQwenSSEResponse,
qwenCollectSettledPageSources,
} }