fix: 问问微信恢复引用编号,日报修复成员头像与 hero 溢出

- 问问微信:Host 分配稳定 citationId 并注入模型上下文,正文 [E#] 与证据按钮同号
- 问问微信:回答返回前校验引用,幻觉编号移除并提示,不再出现不可信的引用按钮
- Agent Hub:收紧「最近会话」快捷路由,内容查询交回 Query Agent
This commit is contained in:
Wxw-Gu
2026-09-17 18:56:32 +08:00
parent 4b2395a8d2
commit b97e1f4bc6
23 changed files with 1635 additions and 228 deletions
+235
View File
@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from 'vitest'
import {
QueryAgentService,
type QueryAgentProvider,
type QueryAgentToolResult
} from '../../src/main/services/query-agent-service'
import type { AIChatToolCall } from '../../src/main/services/ai-provider-service'
/**
* Query Agent 的稳定 Citation 契约。
*
* 四个必须同时成立的条件(缺一,正文里的 `[E#]` 就会消失或变成幻觉引用):
* 1. Host 在**模型调用之前**给 evidence 分配稳定 `citationId`,同一 messageRef 只分配一次;
* 2. `citationId` 随 Tool Result 进入**模型可见**上下文(模型只能引用它见过的编号);
* 3. 模型可见编号集合 ⊆ UI 可见集合,且同号(UI 证据卡 / 底部引用按钮不另行编号);
* 4. 回答返回前经 Host 校验:非法 `[E#]` 移除并回报,绝不进 UI。
*/
function provider(
responses: Array<Awaited<ReturnType<QueryAgentProvider['chatWithTools']>>>,
configured = true
): QueryAgentProvider {
return {
getRuntimeConfig: () => ({
configured,
providerName: 'Fixture Provider',
model: 'fixture-model',
modelName: 'Fixture Model'
}),
chatWithTools: vi.fn(async () => responses.shift() || { success: true, data: 'done' })
}
}
const queryMessagesCall = (id: string): AIChatToolCall => ({
id,
name: 'query_messages',
arguments: JSON.stringify({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
temporalBasis: { kind: 'none' },
limit: 5
})
})
const searchCall = (id: string): AIChatToolCall => ({
id,
name: 'search_messages',
arguments: JSON.stringify({
target: { query: 'BOBO' },
timeRange: { kind: 'all' },
queries: ['topic']
})
})
const contextCall = (id: string, messageRef: string): AIChatToolCall => ({
id,
name: 'message_context',
arguments: JSON.stringify({ messageRef, before: 1, after: 1 })
})
/** 从第 N 次模型调用里取出 tool 消息内容。 */
const toolMessageOf = (mocked: QueryAgentProvider, callIndex: number): Record<string, unknown> => {
const messages = vi.mocked(mocked.chatWithTools).mock.calls[callIndex]?.[0] || []
const toolMessage = messages.find((message) => message.role === 'tool')
expect(toolMessage, `第 ${callIndex + 1} 次模型调用应带 tool 消息`).toBeTruthy()
return JSON.parse(String(toolMessage?.content)) as Record<string, unknown>
}
describe('Query Agent Citation — Host 分配稳定编号', () => {
it('首次命中即分配 E1、E2…,顺序与 evidence 契约一致', async () => {
const execute = vi.fn(
async (): Promise<QueryAgentToolResult> => ({
status: 'completed',
returnedCount: 2,
messages: [{ messageRef: 'ref-1' }, { messageRef: 'ref-2' }]
})
)
const service = new QueryAgentService(
provider([
{ success: true, toolCalls: [queryMessagesCall('call-1')] },
{ success: true, data: '读到了两条。' }
]),
execute
)
const result = await service.run('我和 BOBO 最开始聊了什么')
expect(result.evidence?.map((item) => item.citationId)).toEqual(['E1', 'E2'])
expect(result.evidence?.map((item) => item.messageRef)).toEqual(['ref-1', 'ref-2'])
})
it('同一 messageRef 被第二个 Tool 再次命中时保持原 citationId,不重排、不新增', async () => {
const execute = vi.fn(async (name: string, input: Record<string, unknown>) => {
if (name === 'search_messages') {
return {
status: 'completed',
evidenceCount: 2,
evidence: [{ messageRef: 'ref-1' }, { messageRef: 'ref-2' }]
} as QueryAgentToolResult
}
// message_context:anchor 就是被补语境的那条证据(ref-2 再次命中),
// before/after 不是本次结论的依据,不该进证据列表。
return {
status: 'completed',
anchor: { messageRef: String(input.messageRef) },
before: [{ messageRef: 'before-1' }],
after: [{ messageRef: 'after-1' }]
} as QueryAgentToolResult
})
const configuredProvider = provider([
{ success: true, toolCalls: [searchCall('call-1')] },
{ success: true, toolCalls: [contextCall('call-2', 'ref-2')] },
{ success: true, data: '第二条更重要[E2]。' }
])
const result = await new QueryAgentService(configuredProvider, execute).run('查找相关记录')
// ref-1 → E1,ref-2 → E2;ref-2 被 context 再次命中后编号不变
expect(result.evidence?.map((item) => ({ ref: item.messageRef, id: item.citationId }))).toEqual(
[
{ ref: 'ref-1', id: 'E1' },
{ ref: 'ref-2', id: 'E2' }
]
)
// before/after 不进证据列表
expect(result.evidence?.some((item) => String(item.messageRef).startsWith('before'))).toBe(
false
)
})
it('citationId 随 Tool Result 进入模型可见上下文(否则模型无从引用)', async () => {
const execute = vi.fn(
async (): Promise<QueryAgentToolResult> => ({
status: 'completed',
returnedCount: 2,
messages: [{ messageRef: 'ref-1' }, { messageRef: 'ref-2' }]
})
)
const configuredProvider = provider([
{ success: true, toolCalls: [queryMessagesCall('call-1')] },
{ success: true, data: '完成。' }
])
const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息')
const presented = toolMessageOf(configuredProvider, 1)
const presentedEvidence = presented.messages as Array<Record<string, unknown>>
expect(presentedEvidence.map((item) => item.citationId)).toEqual(['E1', 'E2'])
// 模型可见编号集合 == 契约编号集合(同号,不是另一套 index)
expect(presentedEvidence.map((item) => item.citationId)).toEqual(
result.evidence?.map((item) => item.citationId)
)
})
it('超过证据上限的条目不分配编号,也不带 citationId 进模型上下文', async () => {
const limit = 40
const execute = vi.fn(
async (): Promise<QueryAgentToolResult> => ({
status: 'completed',
returnedCount: limit + 5,
messages: Array.from({ length: limit + 5 }, (_, index) => ({
messageRef: `ref-${index + 1}`
}))
})
)
const configuredProvider = provider([
{ success: true, toolCalls: [queryMessagesCall('call-1')] },
{ success: true, data: '完成。' }
])
const result = await new QueryAgentService(configuredProvider, execute).run('第一条消息')
expect(result.evidence).toHaveLength(limit)
expect(result.evidence?.at(-1)?.citationId).toBe(`E${limit}`)
// 第 41 条以后没有编号:模型上下文里也不该出现它们的 citationId
const presented = toolMessageOf(configuredProvider, 1).messages as Array<
Record<string, unknown>
>
const cited = presented.filter((item) => typeof item.citationId === 'string')
expect(cited).toHaveLength(limit)
expect(presented.filter((item) => item.messageRef === 'ref-41')[0]?.citationId).toBeUndefined()
})
})
describe('Query Agent Citation — Host 侧校验,幻觉编号不得进入 UI', () => {
it('合法的 [E#] 原样保留', async () => {
const execute = vi.fn(
async (): Promise<QueryAgentToolResult> => ({
status: 'completed',
returnedCount: 1,
messages: [{ messageRef: 'ref-1' }]
})
)
const service = new QueryAgentService(
provider([
{ success: true, toolCalls: [queryMessagesCall('call-1')] },
{ success: true, data: '张三提到健身[E1]。' }
]),
execute
)
const result = await service.run('第一条消息')
expect(result.answer).toBe('张三提到健身[E1]。')
expect(result.invalidCitationIds).toBeUndefined()
})
it('不存在的 [E#] 被移除并回报 invalidCitationIds', async () => {
const execute = vi.fn(
async (): Promise<QueryAgentToolResult> => ({
status: 'completed',
returnedCount: 1,
messages: [{ messageRef: 'ref-1' }]
})
)
const service = new QueryAgentService(
provider([
{ success: true, toolCalls: [queryMessagesCall('call-1')] },
{ success: true, data: '张三提到健身[E1] ,另有来源[E9][E99]。' }
]),
execute
)
const result = await service.run('第一条消息')
expect(result.answer).toContain('[E1]')
expect(result.answer).not.toMatch(/\[E(?:9|99)\]/)
expect(result.invalidCitationIds).toEqual(['E9', 'E99'])
})
it('本轮没有任何证据时,回答里的 [E#] 一律移除', async () => {
const configuredProvider = provider([{ success: true, data: '这个群今天聊了健身[E1]。' }])
const result = await new QueryAgentService(configuredProvider, vi.fn()).run('闲聊')
expect(result.answer).toBe('这个群今天聊了健身。')
expect(result.invalidCitationIds).toEqual(['E1'])
// 没有 Tool 就没有证据:白名单为空,所以任何编号都不成立。
// (Runtime 不主动造空数组,Adapter 侧按 `|| []` 兜底,与既有契约一致。)
expect(result.evidence ?? []).toEqual([])
})
})