2026-05-01 20:39:09 +08:00
import { describe , expect , it } from 'vitest'
2026-04-28 22:07:21 +08:00
2026-05-01 20:39:09 +08:00
import { __yuanbaoTestUtils } from './yuanbao'
2026-04-28 22:07:21 +08:00
const {
2026-04-30 17:12:18 +08:00
appendUniqueSourceItems ,
buildOrderedDomSourceItems ,
2026-04-28 22:07:21 +08:00
buildSourceItem ,
extractCitationMarkerIndexes ,
2026-04-30 17:12:18 +08:00
extractQuestionText ,
findUnresolvedCitationIndexes ,
2026-04-28 22:07:21 +08:00
mergeText ,
2026-05-08 21:51:24 +08:00
normalizeCitationMarkers ,
2026-04-28 22:07:21 +08:00
normalizeSourceUrl ,
2026-04-30 17:12:18 +08:00
parseYuanbaoCaptures ,
2026-04-28 22:07:21 +08:00
resolveCitationsFromMarkers ,
2026-05-01 20:39:09 +08:00
} = __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 ( '合肥全屋定制推荐' )
2026-06-22 12:21:59 +08:00
expect ( ( ) = > extractQuestionText ( { title : '监控任务 · 混元 / 元宝' } ) ) . toThrow (
'yuanbao_question_text_missing' ,
)
2026-05-01 20:39:09 +08:00
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' , ( ) = > {
2026-04-28 22:07:21 +08:00
const sources = [
2026-05-01 20:39:09 +08:00
{ 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' , ( ) = > {
2026-04-30 17:12:18 +08:00
const domSources = buildOrderedDomSourceItems ( [
{
2026-05-01 20:39:09 +08:00
url : 'https://example.com/repeated' ,
title : '来源 1' ,
2026-04-30 17:12:18 +08:00
text : null ,
2026-05-01 20:39:09 +08:00
siteName : 'Example' ,
2026-04-30 17:12:18 +08:00
sourceIndex : 1 ,
} ,
{
2026-05-01 20:39:09 +08:00
url : 'https://example.com/repeated' ,
title : '来源 2' ,
2026-04-30 17:12:18 +08:00
text : null ,
2026-05-01 20:39:09 +08:00
siteName : 'Example' ,
2026-04-30 17:12:18 +08:00
sourceIndex : 2 ,
} ,
{
2026-05-01 20:39:09 +08:00
url : 'https://example.com/third' ,
title : '来源 3' ,
2026-04-30 17:12:18 +08:00
text : null ,
2026-05-01 20:39:09 +08:00
siteName : 'Example' ,
2026-04-30 17:12:18 +08:00
sourceIndex : 3 ,
} ,
2026-05-01 20:39:09 +08:00
] )
2026-04-30 17:12:18 +08:00
2026-05-01 20:39:09 +08:00
expect ( domSources ) . toHaveLength ( 3 )
expect ( domSources . map ( ( item ) = > item . title ) ) . toEqual ( [ '来源 1' , '来源 2' , '来源 3' ] )
expect ( findUnresolvedCitationIndexes ( '正文 [citation:2] [citation:3]' , domSources ) ) . toEqual ( [ ] )
} )
2026-04-30 17:12:18 +08:00
2026-05-01 20:39:09 +08:00
it ( 'keeps primary DOM citation order when appending parsed extras' , ( ) = > {
2026-04-30 17:12:18 +08:00
const primary = [
2026-05-01 20:39:09 +08:00
{ url : 'https://example.com/1' , normalized_url : 'https://example.com/1' } ,
{ url : 'https://example.com/2' , normalized_url : 'https://example.com/2' } ,
]
2026-04-30 17:12:18 +08:00
const appended = appendUniqueSourceItems ( primary , [
2026-05-01 20:39:09 +08:00
{ url : 'https://example.com/2' , normalized_url : 'https://example.com/2' , title : 'duplicate' } ,
{ url : 'https://example.com/3' , normalized_url : 'https://example.com/3' } ,
] )
2026-04-30 17:12:18 +08:00
expect ( appended . map ( ( item ) = > item . url ) ) . toEqual ( [
2026-05-01 20:39:09 +08:00
'https://example.com/1' ,
'https://example.com/2' ,
'https://example.com/3' ,
] )
} )
2026-04-30 17:12:18 +08:00
2026-05-01 20:39:09 +08:00
it ( 'does not report incomplete citations when the answer has no markers' , ( ) = > {
expect ( findUnresolvedCitationIndexes ( '正文没有引用小图标' , [ ] ) ) . toEqual ( [ ] )
} )
2026-04-30 17:12:18 +08:00
2026-05-08 21:51:24 +08:00
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 ] )
2026-06-08 11:56:18 +08:00
expect ( extractCitationMarkerIndexes ( '混合两种格式 [citation:3] 还有 [4]' ) ) . toEqual ( [ 3 , 4 ] )
2026-05-08 21:51:24 +08:00
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' , ( ) = > {
2026-06-08 11:56:18 +08:00
expect ( normalizeCitationMarkers ( '正文 [citation:1] 和 [citation:10]' ) ) . toBe ( '正文 [1] 和 [10]' )
2026-05-08 21:51:24 +08:00
// Already-normalised text passes through.
expect ( normalizeCitationMarkers ( '已经是 [1][2] 格式' ) ) . toBe ( '已经是 [1][2] 格式' )
// Null/empty pass through.
expect ( normalizeCitationMarkers ( null ) ) . toBeNull ( )
expect ( normalizeCitationMarkers ( '' ) ) . toBe ( '' )
} )
2026-06-08 11:56:18 +08:00
it ( 'does not invent citations for fast-cache answers (no searchGuid frame, no [N] markers)' , ( ) = > {
2026-05-08 21:51:24 +08:00
// 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 ( [ ] )
} )
2026-06-08 11:56:18 +08:00
it ( 'aligns citations[index-1] with answer markers when SSE places refs in searchGuid.docs (new hunyuan_t1 protocol)' , ( ) = > {
2026-05-08 21:51:24 +08:00
// 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 ( [ ] )
} )
2026-05-01 20:39:09 +08:00
it ( 'collects source candidates from deep search thinking fields' , ( ) = > {
2026-04-30 17:12:18 +08:00
const summary = parseYuanbaoCaptures ( [
{
2026-05-01 20:39:09 +08:00
url : 'https://yuanbao.tencent.com/api/chat' ,
2026-04-30 17:12:18 +08:00
status : 200 ,
2026-05-01 20:39:09 +08:00
contentType : 'text/event-stream' ,
2026-04-30 17:12:18 +08:00
body : [
2026-05-01 20:39:09 +08:00
'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' ) ,
2026-04-30 17:12:18 +08:00
} ,
2026-05-01 20:39:09 +08:00
] )
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' ] )
} )
} )