8c6789dca6
Desktop Client Build / Resolve Build Metadata (push) Successful in 27s
Backend CI / Backend (push) Successful in 15m57s
Desktop Client Build / Build Desktop Client (push) Successful in 22m57s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 27s
274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
|
|
import { __yuanbaoTestUtils } from './yuanbao'
|
|
|
|
const {
|
|
appendUniqueSourceItems,
|
|
buildOrderedDomSourceItems,
|
|
buildSourceItem,
|
|
extractCitationMarkerIndexes,
|
|
extractQuestionText,
|
|
findUnresolvedCitationIndexes,
|
|
mergeText,
|
|
normalizeCitationMarkers,
|
|
normalizeSourceUrl,
|
|
parseYuanbaoCaptures,
|
|
resolveCitationsFromMarkers,
|
|
} = __yuanbaoTestUtils
|
|
|
|
describe('yuanbao adapter helpers', () => {
|
|
it('unwraps redirect URLs and reads Yuanbao document URL fields', () => {
|
|
expect(
|
|
normalizeSourceUrl(
|
|
'https://yuanbao.tencent.com/link?target=https%3A%2F%2Fexample.com%2Fa%23ref',
|
|
),
|
|
).toBe('https://example.com/a')
|
|
|
|
expect(
|
|
buildSourceItem({
|
|
docUrl: 'https://example.com/doc?from=yuanbao#source',
|
|
title: '引用文章',
|
|
siteName: 'Example',
|
|
}),
|
|
).toMatchObject({
|
|
url: 'https://example.com/doc?from=yuanbao',
|
|
normalized_url: 'https://example.com/doc?from=yuanbao',
|
|
title: '引用文章',
|
|
site_name: 'Example',
|
|
host: 'example.com',
|
|
})
|
|
})
|
|
|
|
it('rejects browser and build diagnostics as monitoring questions', () => {
|
|
expect(extractQuestionText({ question_text: '合肥全屋定制推荐' })).toBe('合肥全屋定制推荐')
|
|
expect(() => extractQuestionText({ title: '监控任务 · 混元 / 元宝' })).toThrow(
|
|
'yuanbao_question_text_missing',
|
|
)
|
|
expect(() =>
|
|
extractQuestionText({
|
|
question_text:
|
|
"The resource http://localhost:8000/static/js/runtime.c632b2fe.js was preloaded using link preload but not used within a few seconds from the window's load event.",
|
|
}),
|
|
).toThrow('yuanbao_question_text_runtime_diagnostic')
|
|
expect(() =>
|
|
extractQuestionText({
|
|
question_text:
|
|
'failed to solve: process "/bin/sh -c go build" did not complete successfully: exit code: 1',
|
|
}),
|
|
).toThrow('yuanbao_question_text_runtime_diagnostic')
|
|
})
|
|
|
|
it('merges streaming answer fragments without duplicating a repeated prefix', () => {
|
|
const first =
|
|
'通过三轮搜索,我已经掌握了合肥全屋定制市场的高性价比品牌。一定要合同中的定标准确和责任。'
|
|
const repeated =
|
|
'通过三轮搜索,我已经掌握了合肥全屋定制市场的高性价比品牌。结合你的预算,我建议优先看本土老牌。'
|
|
|
|
expect(mergeText(first, repeated)).toBe(repeated)
|
|
})
|
|
|
|
it('resolves citation markers against the best available source pool', () => {
|
|
const sources = [
|
|
{ url: 'https://example.com/1', normalized_url: 'https://example.com/1' },
|
|
{ url: 'https://example.com/2', normalized_url: 'https://example.com/2' },
|
|
{ url: 'https://example.com/3', normalized_url: 'https://example.com/3' },
|
|
]
|
|
|
|
expect(extractCitationMarkerIndexes('正文 [citation:1] 和 [citation:3]')).toEqual([1, 3])
|
|
expect(
|
|
resolveCitationsFromMarkers('正文 [citation:1] 和 [citation:3]', [[], sources]).map(
|
|
(item) => item.url,
|
|
),
|
|
).toEqual(['https://example.com/1', 'https://example.com/3'])
|
|
})
|
|
|
|
it('preserves indexed DOM citation slots even when URLs repeat', () => {
|
|
const domSources = buildOrderedDomSourceItems([
|
|
{
|
|
url: 'https://example.com/repeated',
|
|
title: '来源 1',
|
|
text: null,
|
|
siteName: 'Example',
|
|
sourceIndex: 1,
|
|
},
|
|
{
|
|
url: 'https://example.com/repeated',
|
|
title: '来源 2',
|
|
text: null,
|
|
siteName: 'Example',
|
|
sourceIndex: 2,
|
|
},
|
|
{
|
|
url: 'https://example.com/third',
|
|
title: '来源 3',
|
|
text: null,
|
|
siteName: 'Example',
|
|
sourceIndex: 3,
|
|
},
|
|
])
|
|
|
|
expect(domSources).toHaveLength(3)
|
|
expect(domSources.map((item) => item.title)).toEqual(['来源 1', '来源 2', '来源 3'])
|
|
expect(findUnresolvedCitationIndexes('正文 [citation:2] [citation:3]', domSources)).toEqual([])
|
|
})
|
|
|
|
it('keeps primary DOM citation order when appending parsed extras', () => {
|
|
const primary = [
|
|
{ url: 'https://example.com/1', normalized_url: 'https://example.com/1' },
|
|
{ url: 'https://example.com/2', normalized_url: 'https://example.com/2' },
|
|
]
|
|
const appended = appendUniqueSourceItems(primary, [
|
|
{ url: 'https://example.com/2', normalized_url: 'https://example.com/2', title: 'duplicate' },
|
|
{ url: 'https://example.com/3', normalized_url: 'https://example.com/3' },
|
|
])
|
|
|
|
expect(appended.map((item) => item.url)).toEqual([
|
|
'https://example.com/1',
|
|
'https://example.com/2',
|
|
'https://example.com/3',
|
|
])
|
|
})
|
|
|
|
it('does not report incomplete citations when the answer has no markers', () => {
|
|
expect(findUnresolvedCitationIndexes('正文没有引用小图标', [])).toEqual([])
|
|
})
|
|
|
|
it("recognises both '[N]' and '[citation:N]' inline markers", () => {
|
|
// The new yuanbao model lowers <div data-idx-list="1,2,10"> placeholders to "[1][2][10]";
|
|
// older SSE frames may still emit "[citation:N]". Both must extract the same indexes.
|
|
expect(extractCitationMarkerIndexes('合肥老牌选 [1][2] 性价比 [10]。')).toEqual([1, 2, 10])
|
|
expect(extractCitationMarkerIndexes('混合两种格式 [citation:3] 还有 [4]')).toEqual([3, 4])
|
|
|
|
const sources = [
|
|
{ url: 'https://example.com/1', normalized_url: 'https://example.com/1' },
|
|
{ url: 'https://example.com/2', normalized_url: 'https://example.com/2' },
|
|
]
|
|
expect(
|
|
resolveCitationsFromMarkers('正文 [1] 然后 [2]', [[], sources]).map((item) => item.url),
|
|
).toEqual(['https://example.com/1', 'https://example.com/2'])
|
|
})
|
|
|
|
it('rewrites legacy [citation:N] markers to bare [N] for SaaS rendering', () => {
|
|
expect(normalizeCitationMarkers('正文 [citation:1] 和 [citation:10]')).toBe('正文 [1] 和 [10]')
|
|
// Already-normalised text passes through.
|
|
expect(normalizeCitationMarkers('已经是 [1][2] 格式')).toBe('已经是 [1][2] 格式')
|
|
// Null/empty pass through.
|
|
expect(normalizeCitationMarkers(null)).toBeNull()
|
|
expect(normalizeCitationMarkers('')).toBe('')
|
|
})
|
|
|
|
it('does not invent citations for fast-cache answers (no searchGuid frame, no [N] markers)', () => {
|
|
// hunyuan_t1 sometimes returns a cached / non-search answer ("已深度思考(用时1秒)"
|
|
// with no "找到了 N 篇相关资料" panel). The SSE then carries only `text`
|
|
// frames and no `searchGuid`. The adapter must still surface that answer to
|
|
// the SaaS pipeline — see the top-level query path: as long as `answer` is
|
|
// non-empty and there are no [N] markers, neither the empty-response nor
|
|
// the incomplete-citations branches fire, so we fall through to status=succeeded.
|
|
const sseFrames = [
|
|
JSON.stringify({ type: 'text', msg: '在合肥,' }),
|
|
JSON.stringify({ type: 'text', msg: '全屋定制市场非常成熟,' }),
|
|
JSON.stringify({ type: 'text', msg: '没有绝对"最好"的商家。' }),
|
|
]
|
|
.map((line) => `data: ${line}`)
|
|
.join('\n\n')
|
|
|
|
const summary = parseYuanbaoCaptures([
|
|
{
|
|
url: 'https://yuanbao.tencent.com/api/chat/cache-hit',
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: sseFrames,
|
|
},
|
|
])
|
|
|
|
expect(summary.answer).toBe('在合肥,全屋定制市场非常成熟,没有绝对"最好"的商家。')
|
|
expect(summary.citations).toEqual([])
|
|
expect(summary.searchResults).toEqual([])
|
|
// No marker → no unresolved indexes, so the adapter does not gate on citations.
|
|
expect(extractCitationMarkerIndexes(summary.answer)).toEqual([])
|
|
expect(findUnresolvedCitationIndexes(summary.answer, summary.citations)).toEqual([])
|
|
})
|
|
|
|
it('aligns citations[index-1] with answer markers when SSE places refs in searchGuid.docs (new hunyuan_t1 protocol)', () => {
|
|
// Captured against the live yuanbao endpoint: deep-search now ships every
|
|
// reference inside `searchGuid.docs[]` with a 1-based `index`, and the
|
|
// legacy `citations` field is always null in this frame. Whatever the
|
|
// adapter normalises to must preserve idx alignment so that the
|
|
// unresolved-citations check passes.
|
|
const sseFrames = [
|
|
JSON.stringify({
|
|
type: 'searchGuid',
|
|
title: '引用 3 篇资料作为参考',
|
|
docs: [
|
|
{
|
|
index: 1,
|
|
title: '志邦家居官网',
|
|
url: 'https://www.zbom.com/about',
|
|
web_url: 'https://www.zbom.com/about',
|
|
web_site_name: '志邦家居',
|
|
},
|
|
{
|
|
index: 2,
|
|
title: '合肥本地装修评测',
|
|
url: 'https://example.com/hefei-review',
|
|
web_site_name: '今日头条',
|
|
},
|
|
{
|
|
index: 3,
|
|
title: '客来福官方介绍',
|
|
url: 'https://example.com/kelaifu',
|
|
web_site_name: '客来福',
|
|
},
|
|
],
|
|
citations: null,
|
|
}),
|
|
JSON.stringify({
|
|
type: 'text',
|
|
msg: '志邦家居 [1] 与客来福 [3] 都是合肥本土头部 [2]。',
|
|
}),
|
|
]
|
|
.map((line) => `data: ${line}`)
|
|
.join('\n\n')
|
|
|
|
const summary = parseYuanbaoCaptures([
|
|
{
|
|
url: 'https://yuanbao.tencent.com/api/chat/abc',
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: sseFrames,
|
|
},
|
|
])
|
|
|
|
expect(summary.citations.map((item) => item.url)).toEqual([
|
|
'https://www.zbom.com/about',
|
|
'https://example.com/hefei-review',
|
|
'https://example.com/kelaifu',
|
|
])
|
|
expect(findUnresolvedCitationIndexes(summary.answer, summary.citations)).toEqual([])
|
|
})
|
|
|
|
it('collects source candidates from deep search thinking fields', () => {
|
|
const summary = parseYuanbaoCaptures([
|
|
{
|
|
url: 'https://yuanbao.tencent.com/api/chat',
|
|
status: 200,
|
|
contentType: 'text/event-stream',
|
|
body: [
|
|
'data: {"type":"deepSearch","contents":[{"type":"step","webPages":[{"targetUrl":"https://example.com/a#ref","title":"来源 A","siteName":"Example"}],"references":[{"sourceUrl":"https://example.com/b","title":"来源 B"}]}]}',
|
|
'data: {"type":"text","msg":"正文 [citation:1] [citation:2]"}',
|
|
].join('\n\n'),
|
|
},
|
|
])
|
|
|
|
expect(summary.searchResults.map((item) => item.url)).toContain('https://example.com/a')
|
|
expect(summary.citations.map((item) => item.url)).toContain('https://example.com/b')
|
|
expect(
|
|
resolveCitationsFromMarkers(summary.answer, [
|
|
summary.citations,
|
|
summary.searchResults,
|
|
[...summary.citations, ...summary.searchResults],
|
|
]).map((item) => item.url),
|
|
).toEqual(['https://example.com/b', 'https://example.com/a'])
|
|
})
|
|
})
|