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