feat(qwen): implement source fallback mechanism and add tests for settled page sources
This commit is contained in:
@@ -1,9 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { AdapterContext } from './base'
|
||||
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 {
|
||||
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', () => {
|
||||
it('expands an internal tab-container reference into the selected answer', async () => {
|
||||
const page = {
|
||||
@@ -254,8 +334,78 @@ describe('qwen adapter query result validation', () => {
|
||||
|
||||
expect(result.status).toBe('succeeded')
|
||||
expect(result.payload?.answer).toBe('官方 SSE 完整答案')
|
||||
expect(
|
||||
(result.payload?.raw_response_json as Record<string, unknown>).response_source,
|
||||
).toBe('api_sse')
|
||||
expect((result.payload?.raw_response_json as Record<string, unknown>).response_source).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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { normalizeText } from './common'
|
||||
const QWEN_BOOTSTRAP_URL = 'https://www.qianwen.com/'
|
||||
const QWEN_PAGE_READY_TIMEOUT_MS = 20_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_REFERENCE_PATTERN = /\[\(tab_container_\d+\)\]/i
|
||||
|
||||
@@ -50,6 +51,11 @@ type QwenParsedSSEResponse = {
|
||||
events: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
type QwenPageSourceFallbackResult = {
|
||||
elapsedMs: number
|
||||
sourcePayloads: unknown[]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -220,9 +226,7 @@ function resolveQwenTabContainerText(card: unknown): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
const tabs = Array.isArray(card.content.tabs)
|
||||
? card.content.tabs.filter(isRecord)
|
||||
: []
|
||||
const tabs = Array.isArray(card.content.tabs) ? card.content.tabs.filter(isRecord) : []
|
||||
const selectedTabId =
|
||||
normalizeOptionalString(card.content.selected_tab) ??
|
||||
normalizeOptionalString(card.content.default_tab)
|
||||
@@ -384,10 +388,7 @@ function parseQwenSSEResponse(body: string): QwenParsedSSEResponse {
|
||||
for (const payload of payloads) {
|
||||
const data = isRecord(payload.data) ? payload.data : payload
|
||||
extraInfo = mergeQwenSSERecord(extraInfo, data.extra_info ?? data.extraInfo)
|
||||
communication = mergeQwenSSERecord(
|
||||
communication,
|
||||
data.communication ?? data.communication_info,
|
||||
)
|
||||
communication = mergeQwenSSERecord(communication, data.communication ?? data.communication_info)
|
||||
}
|
||||
|
||||
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 = {
|
||||
provider: 'qwen',
|
||||
executionMode: 'playwright',
|
||||
@@ -890,7 +1035,26 @@ export const qwenAdapter: MonitorAdapter = {
|
||||
const searchResults: MonitoringSourceItem[] = []
|
||||
collectQwenSources(apiResult?.events, 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) {
|
||||
return {
|
||||
@@ -941,6 +1105,11 @@ export const qwenAdapter: MonitorAdapter = {
|
||||
question: toJsonValue(pageResult.question),
|
||||
source_count: dedupedSearchResults.length,
|
||||
search_results: toJsonSources(dedupedSearchResults),
|
||||
source_fallback: {
|
||||
used: sourceFallbackAttempted,
|
||||
elapsed_ms: sourceFallbackResult?.elapsedMs ?? null,
|
||||
payload_count: sourceFallbackPayloads.length,
|
||||
},
|
||||
content: toJsonValue(apiResult?.content ?? pageResult.answer.content),
|
||||
extra_info: toJsonValue(apiResult?.extraInfo ?? pageResult.answer.extraInfo),
|
||||
communication: toJsonValue(apiResult?.communication ?? pageResult.answer.communication),
|
||||
@@ -956,4 +1125,5 @@ export const __qwenTestUtils = {
|
||||
isQwenInternalPlaceholderOnly,
|
||||
resolveQwenAnswerText,
|
||||
parseQwenSSEResponse,
|
||||
qwenCollectSettledPageSources,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user