mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 知识库更新,优化批量语音转写 - 合并索引 - 增加会话级批量转写处理 - 补充语音转写回归测试
This commit is contained in:
@@ -87,4 +87,78 @@ describe('voice recognition settings', () => {
|
||||
expect(screen.getByText('正在下载 42%')).toBeInTheDocument()
|
||||
finishDownload?.({ success: true, status: readyStatus })
|
||||
})
|
||||
|
||||
it('categorizes conversations and shows voice counts before they are selected', async () => {
|
||||
window.api = {
|
||||
...window.api,
|
||||
getContacts: vi.fn().mockResolvedValue([
|
||||
{ md5: 'contact-a', m_nsNickName: '联系人 A', m_nsUsrName: 'contact-a', type: 'user' },
|
||||
{ md5: 'group-b', m_nsNickName: '群聊 B', m_nsUsrName: 'group-b@chatroom', type: 'group' }
|
||||
]),
|
||||
getSelf: vi.fn().mockResolvedValue({
|
||||
ready: true,
|
||||
info: { wxid: 'wxid_fixture', nickname: '测试账号', accountRoot: 'C:/fixture' }
|
||||
}),
|
||||
getVoiceBatchConversationSummaries: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ conversationId: 'group-b', voiceMessageCount: 7 }]),
|
||||
getVoiceBatchProgress: vi.fn().mockResolvedValue({
|
||||
accountIdentity: 'account-a',
|
||||
state: 'idle',
|
||||
total: 0,
|
||||
processed: 0,
|
||||
cached: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
elapsedMs: 0,
|
||||
estimatedRemainingMs: null
|
||||
}),
|
||||
getVoiceBatchPreflight: vi.fn().mockResolvedValue({
|
||||
accountIdentity: 'account-a',
|
||||
conversationCount: 2,
|
||||
voiceMessageCount: 8,
|
||||
cachedCount: 3,
|
||||
pendingCount: 5,
|
||||
failedCount: 0,
|
||||
estimatedDurationMs: null,
|
||||
modelReady: true
|
||||
}),
|
||||
startVoiceBatch: vi.fn().mockResolvedValue({
|
||||
accountIdentity: 'account-a',
|
||||
state: 'pending',
|
||||
total: 8,
|
||||
processed: 0,
|
||||
cached: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
elapsedMs: 0,
|
||||
estimatedRemainingMs: null
|
||||
}),
|
||||
cancelVoiceBatch: vi.fn().mockResolvedValue({ success: true }),
|
||||
retryFailedVoiceBatch: vi.fn(),
|
||||
onVoiceBatchProgress: vi.fn(() => vi.fn())
|
||||
} as typeof window.api
|
||||
render(<VoiceRecognitionPage onNotice={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('群聊 B')).toBeInTheDocument()
|
||||
expect(await screen.findByText('7 条语音')).toBeInTheDocument()
|
||||
expect(window.api.getVoiceBatchConversationSummaries).toHaveBeenCalledWith({
|
||||
conversationIds: ['group-b'],
|
||||
range: 'recent_30_days'
|
||||
})
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /群聊 B/ }))
|
||||
await userEvent.click(screen.getByRole('tab', { name: /联系人 1/ }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /联系人 A/ }))
|
||||
expect(screen.getByText('已选会话')).toBeInTheDocument()
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(window.api.getVoiceBatchPreflight).not.toHaveBeenCalled()
|
||||
await userEvent.click(screen.getByRole('button', { name: '开始转写' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(window.api.startVoiceBatch).toHaveBeenCalledWith({
|
||||
conversationIds: ['group-b', 'contact-a'],
|
||||
range: 'recent_30_days'
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ const candidate = (
|
||||
endTime: 1_785_895_200_000 + index,
|
||||
timestamp: 1_785_895_200_000 + index,
|
||||
messageIds: [`message-${index}`],
|
||||
sourceKind: 'text',
|
||||
text: `第 ${index} 条去健身相关消息`,
|
||||
score: -index,
|
||||
...options
|
||||
@@ -64,6 +65,12 @@ describe('Final Evidence builder', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves voice source type through Final Evidence', () => {
|
||||
const result = buildFinalEvidence([candidate(1, { sourceKind: 'voice' })], 8)
|
||||
|
||||
expect(result.evidence[0]).toMatchObject({ id: 'E1', sourceKind: 'voice' })
|
||||
})
|
||||
|
||||
it('removes citations which do not resolve to Final Evidence', () => {
|
||||
const evidence = buildFinalEvidence([candidate(1), candidate(2)], 8).evidence
|
||||
const result = sanitizeAnswerCitations('杨伟提到健身。[E1] 另有无效来源。[E10][E23]', evidence)
|
||||
|
||||
@@ -212,7 +212,7 @@ describe('AiSearchPipelineService', () => {
|
||||
)
|
||||
|
||||
const answerPrompt = aiProvider.chat.mock.calls[2][0][1].content as string
|
||||
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nsender:/g)).map(
|
||||
const contextIds = Array.from(answerPrompt.matchAll(/\[E(\d+)\]\nsource:/g)).map(
|
||||
(match) => Number(match[1])
|
||||
)
|
||||
expect(contextIds).toEqual([1, 2, 3, 4, 5, 6, 7, 8])
|
||||
|
||||
@@ -12,7 +12,20 @@ const { chatState, getGroupSnapshotAsync, listContactsAsync, listMessagesAsync,
|
||||
knowledgeService: {
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
index: vi.fn().mockResolvedValue(undefined),
|
||||
search: vi.fn()
|
||||
search: vi.fn(),
|
||||
status: vi.fn().mockResolvedValue({
|
||||
accountId: 'fixture-account',
|
||||
state: 'ready',
|
||||
indexedMessageCount: 1,
|
||||
indexedChunkCount: 1,
|
||||
sourceMessageCount: 1,
|
||||
processedMessages: 1,
|
||||
totalMessages: 1,
|
||||
estimatedRemainingMs: null,
|
||||
databaseBytes: 0,
|
||||
walBytes: 0,
|
||||
shmBytes: 0
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -30,10 +43,12 @@ vi.mock('../../src/main/knowledge/knowledge-service', () => ({
|
||||
dispose = knowledgeService.dispose
|
||||
index = knowledgeService.index
|
||||
search = knowledgeService.search
|
||||
status = knowledgeService.status
|
||||
}
|
||||
}))
|
||||
|
||||
import { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
|
||||
import type { VoiceTranscriptUpdate } from '../../src/shared/voice-recognition'
|
||||
|
||||
describe('KnowledgeSearchService legacy fallback', () => {
|
||||
beforeEach(() => {
|
||||
@@ -45,6 +60,7 @@ describe('KnowledgeSearchService legacy fallback', () => {
|
||||
knowledgeService.dispose.mockClear()
|
||||
knowledgeService.index.mockClear()
|
||||
knowledgeService.search.mockReset()
|
||||
knowledgeService.status.mockClear()
|
||||
listContactsAsync.mockResolvedValue([
|
||||
{
|
||||
m_nsUsrName: 'fixture-contact',
|
||||
@@ -98,6 +114,140 @@ describe('KnowledgeSearchService legacy fallback', () => {
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('hydrates a cached voice transcript and incrementally indexes only its conversation', async () => {
|
||||
chatState.ready = true
|
||||
chatState.accountId = 'C:/fixtures/account-a'
|
||||
listContactsAsync.mockResolvedValue([
|
||||
{
|
||||
m_nsUsrName: 'voice-contact',
|
||||
m_nsNickName: '语音测试会话',
|
||||
md5: 'voice-conversation',
|
||||
type: 'user'
|
||||
}
|
||||
])
|
||||
listMessagesAsync.mockResolvedValue([
|
||||
{
|
||||
id: 'voice-message',
|
||||
localId: 18,
|
||||
from: 'user',
|
||||
type: '语音',
|
||||
content: '[语音消息]',
|
||||
isSender: false,
|
||||
senderId: 'fixture-sender',
|
||||
name: '脱敏成员',
|
||||
sessionId: 'voice-contact',
|
||||
createTime: 1_785_895_200
|
||||
}
|
||||
])
|
||||
const { voiceAccountIdentity, voiceMessageIdentity } = await import(
|
||||
'../../src/main/voice-pipeline/voice-message-identity'
|
||||
)
|
||||
const reference = {
|
||||
sessionId: 'voice-contact',
|
||||
localId: 18,
|
||||
createTime: 1_785_895_200
|
||||
}
|
||||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||||
service.setVoiceTranscriptResolver(() => ({ state: 'transcribed', transcript: '缓存中的语音文字' }))
|
||||
|
||||
await service.indexVoiceTranscript({
|
||||
accountIdentity: voiceAccountIdentity(chatState.accountId),
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: 'transcribed',
|
||||
transcript: '缓存中的语音文字',
|
||||
cached: true
|
||||
})
|
||||
|
||||
expect(knowledgeService.index).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: chatState.accountId,
|
||||
conversations: [
|
||||
expect.objectContaining({
|
||||
conversationId: 'voice-conversation',
|
||||
completeSnapshot: true,
|
||||
messages: [
|
||||
expect.objectContaining({
|
||||
kind: 'voice',
|
||||
voiceTranscript: '缓存中的语音文字',
|
||||
voiceTranscriptState: 'transcribed'
|
||||
})
|
||||
]
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('coalesces consecutive voice updates for the same conversation', async () => {
|
||||
chatState.ready = true
|
||||
chatState.accountId = 'C:/fixtures/account-a'
|
||||
listContactsAsync.mockResolvedValue([
|
||||
{
|
||||
m_nsUsrName: 'voice-contact',
|
||||
m_nsNickName: '语音测试会话',
|
||||
md5: 'voice-conversation',
|
||||
type: 'user'
|
||||
}
|
||||
])
|
||||
listMessagesAsync.mockResolvedValue([
|
||||
{
|
||||
id: 'voice-message-1',
|
||||
localId: 18,
|
||||
from: 'user',
|
||||
type: '语音',
|
||||
content: '[语音消息]',
|
||||
isSender: false,
|
||||
sessionId: 'voice-contact',
|
||||
createTime: 1_785_895_200
|
||||
},
|
||||
{
|
||||
id: 'voice-message-2',
|
||||
localId: 19,
|
||||
from: 'user',
|
||||
type: '语音',
|
||||
content: '[语音消息]',
|
||||
isSender: false,
|
||||
sessionId: 'voice-contact',
|
||||
createTime: 1_785_895_201
|
||||
}
|
||||
])
|
||||
let releaseFirstIndex: (() => void) | undefined
|
||||
knowledgeService.index.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirstIndex = resolve
|
||||
})
|
||||
)
|
||||
const { voiceAccountIdentity, voiceMessageIdentity } = await import(
|
||||
'../../src/main/voice-pipeline/voice-message-identity'
|
||||
)
|
||||
const service = new KnowledgeSearchService('/tmp/wxe-knowledge-fallback', '/missing-worker.js')
|
||||
const update = (localId: number, createTime: number): VoiceTranscriptUpdate => {
|
||||
const reference = { sessionId: 'voice-contact', localId, createTime }
|
||||
return {
|
||||
accountIdentity: voiceAccountIdentity(chatState.accountId),
|
||||
reference,
|
||||
messageIdentity: voiceMessageIdentity(reference),
|
||||
state: 'transcribed' as const,
|
||||
transcript: `转写 ${localId}`,
|
||||
cached: false
|
||||
}
|
||||
}
|
||||
|
||||
const first = service.indexVoiceTranscript(update(18, 1_785_895_200))
|
||||
await vi.waitFor(() => expect(knowledgeService.index).toHaveBeenCalledTimes(1))
|
||||
const second = service.indexVoiceTranscript(update(19, 1_785_895_201))
|
||||
const third = service.indexVoiceTranscript(update(18, 1_785_895_200))
|
||||
releaseFirstIndex?.()
|
||||
|
||||
await Promise.all([first, second, third])
|
||||
expect(knowledgeService.index).toHaveBeenCalledTimes(2)
|
||||
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
|
||||
await service.dispose()
|
||||
})
|
||||
|
||||
it('uses existing Knowledge evidence while a new incremental pass is running', async () => {
|
||||
chatState.ready = true
|
||||
chatState.accountId = 'fixture-account'
|
||||
|
||||
@@ -215,6 +215,64 @@ describe('knowledge sqlite', () => {
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('marks voice Evidence and reports scoped transcript coverage without indexing error text', async () => {
|
||||
const root = makeRoot()
|
||||
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||
await store.index({
|
||||
conversations: [
|
||||
{
|
||||
conversationId: 'voice-coverage',
|
||||
completeSnapshot: true,
|
||||
messages: [
|
||||
{
|
||||
accountId: FIXTURE_ACCOUNT_A,
|
||||
conversationId: 'voice-coverage',
|
||||
messageId: 'voice-ready',
|
||||
createTime: Date.UTC(2026, 7, 5, 9),
|
||||
senderName: '成员甲',
|
||||
kind: 'voice',
|
||||
text: '[语音消息]',
|
||||
voiceTranscript: '语音里确认今天去健身。',
|
||||
voiceTranscriptState: 'transcribed'
|
||||
},
|
||||
{
|
||||
accountId: FIXTURE_ACCOUNT_A,
|
||||
conversationId: 'voice-coverage',
|
||||
messageId: 'voice-failed',
|
||||
createTime: Date.UTC(2026, 7, 5, 10),
|
||||
senderName: '成员乙',
|
||||
kind: 'voice',
|
||||
text: '[语音消息]',
|
||||
voiceTranscriptState: 'failed'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
chunker: DEFAULT_KNOWLEDGE_CHUNKER
|
||||
})
|
||||
|
||||
const result = store.searchWithStatus({
|
||||
accountId: FIXTURE_ACCOUNT_A,
|
||||
text: '去健身',
|
||||
terms: ['去健身'],
|
||||
conversationIds: ['voice-coverage'],
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(result.evidence[0]).toMatchObject({
|
||||
messageId: 'voice-ready',
|
||||
sourceKind: 'voice'
|
||||
})
|
||||
expect(result.voiceCoverage).toEqual({
|
||||
voiceMessageCount: 2,
|
||||
transcribedVoiceCount: 1,
|
||||
failedVoiceCount: 1,
|
||||
voiceCoverageComplete: false
|
||||
})
|
||||
expect(result.evidence[0].text).not.toContain('失败')
|
||||
store.close()
|
||||
})
|
||||
|
||||
it('keeps conversation, sender and time filters when a participant question has no topic terms', async () => {
|
||||
const root = makeRoot()
|
||||
const store = new KnowledgeStore(root, FIXTURE_ACCOUNT_A, fts)
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('search cache', () => {
|
||||
it('writes and reads an isolated cache record', () => {
|
||||
const key = buildSearchCacheKey('conversation', 'fixture-contact', 'today', '图片')
|
||||
const record = {
|
||||
version: 2 as const,
|
||||
version: 3 as const,
|
||||
key,
|
||||
query: '图片',
|
||||
answer: '固定假回答',
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { VoiceRecognitionUseCase } from '../../src/main/voice-pipeline/voice-recognition-use-case'
|
||||
|
||||
const { countVoiceMessagesAsync, listContactsAsync, listMessagesAsync } = vi.hoisted(() => ({
|
||||
countVoiceMessagesAsync: vi.fn(),
|
||||
listContactsAsync: vi.fn(),
|
||||
listMessagesAsync: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../../src/main/services/chat-service', () => ({
|
||||
countVoiceMessagesAsync,
|
||||
listContactsAsync,
|
||||
listMessagesAsync
|
||||
}))
|
||||
|
||||
import { VoiceBatchService } from '../../src/main/voice-pipeline/voice-batch-service'
|
||||
|
||||
const readyStatus = {
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: 'fixture',
|
||||
state: 'ready' as const,
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 1,
|
||||
progress: 1,
|
||||
platform: 'win32' as const,
|
||||
architecture: 'x64',
|
||||
supported: true
|
||||
}
|
||||
|
||||
function makeRecognition(): VoiceRecognitionUseCase {
|
||||
return {
|
||||
accountIdentity: 'account-a',
|
||||
getModelStatus: vi.fn().mockResolvedValue(readyStatus),
|
||||
getTranscriptSnapshot: vi.fn().mockReturnValue({ state: 'pending' }),
|
||||
recognize: vi.fn().mockResolvedValue({ success: true, transcript: '转写结果', cached: false }),
|
||||
publishTranscriptSnapshot: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as VoiceRecognitionUseCase
|
||||
}
|
||||
|
||||
describe('VoiceBatchService', () => {
|
||||
beforeEach(() => {
|
||||
listContactsAsync.mockReset()
|
||||
listMessagesAsync.mockReset()
|
||||
countVoiceMessagesAsync.mockReset()
|
||||
listContactsAsync.mockResolvedValue([
|
||||
{ md5: 'contact-a', m_nsUsrName: 'contact-a-id', m_nsNickName: '联系人 A', type: 'user' },
|
||||
{ md5: 'group-b', m_nsUsrName: 'group-b@chatroom', m_nsNickName: '群聊 B', type: 'group' }
|
||||
])
|
||||
listMessagesAsync.mockImplementation(async (conversationId: string) => [
|
||||
{
|
||||
id: `${conversationId}-voice`,
|
||||
type: '语音',
|
||||
content: '[语音消息]',
|
||||
isSender: false,
|
||||
sessionId: conversationId === 'contact-a' ? 'contact-a-id' : 'group-b@chatroom',
|
||||
localId: conversationId === 'contact-a' ? 11 : 22,
|
||||
createTime: 1_785_895_200
|
||||
},
|
||||
{
|
||||
id: `${conversationId}-text`,
|
||||
type: '普通文本',
|
||||
content: '不会进入语音任务',
|
||||
isSender: false,
|
||||
createTime: 1_785_895_201
|
||||
}
|
||||
])
|
||||
countVoiceMessagesAsync.mockImplementation(async (conversationId: string) =>
|
||||
conversationId === 'contact-a' ? 3 : 7
|
||||
)
|
||||
})
|
||||
|
||||
it('limits a batch to selected contacts and groups, then schedules each item as background work', async () => {
|
||||
const recognition = makeRecognition()
|
||||
const service = new VoiceBatchService(recognition)
|
||||
const preflight = await service.preflight({
|
||||
conversationIds: ['contact-a', 'group-b'],
|
||||
range: 'recent_30_days'
|
||||
})
|
||||
|
||||
expect(preflight).toMatchObject({
|
||||
conversationCount: 2,
|
||||
voiceMessageCount: 2,
|
||||
cachedCount: 0,
|
||||
pendingCount: 2,
|
||||
modelReady: true
|
||||
})
|
||||
expect(listMessagesAsync).toHaveBeenCalledTimes(2)
|
||||
expect(listMessagesAsync.mock.calls[0][1]).toEqual(expect.any(Number))
|
||||
|
||||
await service.start({ conversationIds: ['contact-a'], range: 'selected_history' })
|
||||
await vi.waitFor(() => expect(service.getProgress().state).toBe('completed'))
|
||||
expect(recognition.recognize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'contact-a-id', localId: 11 }),
|
||||
{ priority: 'background', publishTranscriptUpdate: false }
|
||||
)
|
||||
expect(recognition.recognize).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() =>
|
||||
expect(recognition.publishTranscriptSnapshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId: 'contact-a-id', localId: 11 })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('defers knowledge notifications until the batch finishes and sends one per conversation', async () => {
|
||||
listMessagesAsync.mockResolvedValue([
|
||||
{
|
||||
id: 'contact-a-voice-1',
|
||||
type: '语音',
|
||||
content: '[语音消息]',
|
||||
isSender: false,
|
||||
sessionId: 'contact-a-id',
|
||||
localId: 11,
|
||||
createTime: 1_785_895_200
|
||||
},
|
||||
{
|
||||
id: 'contact-a-voice-2',
|
||||
type: '语音',
|
||||
content: '[语音消息]',
|
||||
isSender: false,
|
||||
sessionId: 'contact-a-id',
|
||||
localId: 12,
|
||||
createTime: 1_785_895_201
|
||||
}
|
||||
])
|
||||
const recognition = makeRecognition()
|
||||
let releaseKnowledgeRefresh: (() => void) | undefined
|
||||
vi.mocked(recognition.publishTranscriptSnapshot).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseKnowledgeRefresh = resolve
|
||||
})
|
||||
)
|
||||
const service = new VoiceBatchService(recognition)
|
||||
|
||||
await service.start({ conversationIds: ['contact-a'], range: 'selected_history' })
|
||||
await vi.waitFor(() => expect(recognition.publishTranscriptSnapshot).toHaveBeenCalledTimes(1))
|
||||
expect(service.getProgress().state).toBe('processing')
|
||||
releaseKnowledgeRefresh?.()
|
||||
await vi.waitFor(() => expect(service.getProgress().state).toBe('completed'))
|
||||
|
||||
expect(recognition.recognize).toHaveBeenCalledTimes(2)
|
||||
expect(recognition.recognize).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
expect.objectContaining({ publishTranscriptUpdate: false })
|
||||
)
|
||||
expect(recognition.publishTranscriptSnapshot).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not start when the selected range contains a stale conversation id', async () => {
|
||||
const service = new VoiceBatchService(makeRecognition())
|
||||
|
||||
await expect(
|
||||
service.preflight({ conversationIds: ['missing-conversation'], range: 'recent_30_days' })
|
||||
).rejects.toThrow('选择的会话已不可用')
|
||||
})
|
||||
|
||||
it('reports cache hits separately from newly recognized items', async () => {
|
||||
const recognition = makeRecognition()
|
||||
vi.mocked(recognition.getTranscriptSnapshot).mockReturnValue({
|
||||
state: 'transcribed',
|
||||
transcript: '旧缓存'
|
||||
})
|
||||
vi.mocked(recognition.recognize).mockResolvedValue({
|
||||
success: true,
|
||||
transcript: '旧缓存',
|
||||
cached: true
|
||||
})
|
||||
const service = new VoiceBatchService(recognition)
|
||||
|
||||
await service.start({ conversationIds: ['contact-a'], range: 'selected_history' })
|
||||
await vi.waitFor(() => expect(service.getProgress().state).toBe('completed'))
|
||||
expect(service.getProgress()).toMatchObject({ cached: 1, succeeded: 0, failed: 0 })
|
||||
})
|
||||
|
||||
it('counts visible conversations through the lightweight voice-count path without loading messages', async () => {
|
||||
const service = new VoiceBatchService(makeRecognition())
|
||||
|
||||
await expect(
|
||||
service.conversationSummaries({
|
||||
conversationIds: ['contact-a', 'group-b'],
|
||||
range: 'recent_30_days'
|
||||
})
|
||||
).resolves.toEqual([
|
||||
{ conversationId: 'contact-a', voiceMessageCount: 3 },
|
||||
{ conversationId: 'group-b', voiceMessageCount: 7 }
|
||||
])
|
||||
|
||||
expect(countVoiceMessagesAsync).toHaveBeenCalledTimes(2)
|
||||
expect(listMessagesAsync).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -101,6 +101,55 @@ describe('voice task scheduling', () => {
|
||||
releaseFirst?.()
|
||||
await first
|
||||
})
|
||||
|
||||
it('runs an interactive request before queued background work', async () => {
|
||||
const scheduler = new VoiceTaskScheduler()
|
||||
const order: string[] = []
|
||||
let releaseFirst: (() => void) | undefined
|
||||
const first = scheduler.schedule(
|
||||
'first',
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
order.push('first')
|
||||
releaseFirst = resolve
|
||||
})
|
||||
)
|
||||
const background = scheduler.schedule('background', async () => {
|
||||
order.push('background')
|
||||
}, { priority: 'background' })
|
||||
const interactive = scheduler.schedule('interactive', async () => {
|
||||
order.push('interactive')
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(order).toEqual(['first']))
|
||||
releaseFirst?.()
|
||||
await Promise.all([first, background, interactive])
|
||||
expect(order).toEqual(['first', 'interactive', 'background'])
|
||||
})
|
||||
|
||||
it('interrupts an active background task for an interactive request', async () => {
|
||||
const scheduler = new VoiceTaskScheduler()
|
||||
const order: string[] = []
|
||||
const background = scheduler.schedule(
|
||||
'background',
|
||||
async (signal) => {
|
||||
order.push('background:start')
|
||||
await new Promise<void>((resolve) => signal.addEventListener('abort', resolve, { once: true }))
|
||||
order.push('background:aborted')
|
||||
throw new DOMException('Recognition cancelled', 'AbortError')
|
||||
},
|
||||
{ priority: 'background' }
|
||||
)
|
||||
await vi.waitFor(() => expect(order).toEqual(['background:start']))
|
||||
const interactive = scheduler.schedule('interactive', async () => {
|
||||
order.push('interactive')
|
||||
return 'done'
|
||||
})
|
||||
|
||||
await expect(background).rejects.toMatchObject({ name: 'AbortError' })
|
||||
await expect(interactive).resolves.toBe('done')
|
||||
expect(order).toEqual(['background:start', 'background:aborted', 'interactive'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('transcript repository', () => {
|
||||
@@ -136,6 +185,20 @@ describe('transcript repository', () => {
|
||||
expect(repository.find(key)).toMatchObject({ transcript: '固定测试文本' })
|
||||
expect(repository.find({ ...key, accountId: 'account-b' })).toBeNull()
|
||||
expect(repository.find({ ...key, modelFingerprint: 'fingerprint-b' })).toBeNull()
|
||||
expect(repository.findLatest(record.accountId, record.messageIdentity)).toMatchObject({
|
||||
transcript: '固定测试文本'
|
||||
})
|
||||
expect(repository.getMessageStatus(record.accountId, record.messageIdentity)).toMatchObject({
|
||||
state: 'transcribed'
|
||||
})
|
||||
repository.markFailure('account-b', record.messageIdentity, '脱敏失败原因')
|
||||
expect(repository.getMessageStatus('account-b', record.messageIdentity)).toMatchObject({
|
||||
state: 'failed',
|
||||
error: '脱敏失败原因'
|
||||
})
|
||||
expect(repository.getMessageStatus(record.accountId, record.messageIdentity)).toMatchObject({
|
||||
state: 'transcribed'
|
||||
})
|
||||
repository.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { VoiceRecognitionUseCase } from '../../src/main/voice-pipeline/voice-recognition-use-case'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
function createUseCase(): VoiceRecognitionUseCase {
|
||||
const root = mkdtempSync(join(tmpdir(), 'wxe-voice-use-case-'))
|
||||
roots.push(root)
|
||||
const useCase = new VoiceRecognitionUseCase({
|
||||
modelRoot: join(root, 'model'),
|
||||
databasePath: join(root, 'transcripts.sqlite'),
|
||||
workerPath: join(root, 'unused-worker.js')
|
||||
})
|
||||
const state = useCase as unknown as {
|
||||
accountId: string
|
||||
accountGeneration: number
|
||||
pipeline: { run: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
state.accountId = 'account-a'
|
||||
state.accountGeneration = 1
|
||||
state.pipeline = { run: vi.fn() }
|
||||
vi.spyOn(useCase.modelManager, 'getStatus').mockResolvedValue({
|
||||
modelId: 'sensevoice-small-int8',
|
||||
version: 'fixture',
|
||||
state: 'ready',
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 1,
|
||||
progress: 1,
|
||||
platform: 'win32',
|
||||
architecture: 'x64',
|
||||
supported: true
|
||||
})
|
||||
return useCase
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('VoiceRecognitionUseCase transcript updates', () => {
|
||||
it('publishes a successful cache hit through the same update path as fresh recognition', async () => {
|
||||
const useCase = createUseCase()
|
||||
const state = useCase as unknown as {
|
||||
pipeline: { run: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
state.pipeline.run.mockResolvedValue({
|
||||
transcript: '缓存命中的语音文字',
|
||||
durationMs: 1_200,
|
||||
cached: true
|
||||
})
|
||||
const listener = vi.fn().mockResolvedValue(undefined)
|
||||
useCase.onTranscriptUpdate(listener)
|
||||
const reference = { sessionId: 'fixture-contact', localId: 9, createTime: 1_785_895_200 }
|
||||
|
||||
const result = await useCase.recognize(reference)
|
||||
|
||||
expect(result).toMatchObject({ success: true, cached: true, transcript: '缓存命中的语音文字' })
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountIdentity: 'account-a',
|
||||
reference,
|
||||
state: 'transcribed',
|
||||
transcript: '缓存命中的语音文字',
|
||||
cached: true
|
||||
})
|
||||
)
|
||||
await useCase.dispose()
|
||||
})
|
||||
|
||||
it('does not publish a transcript after the account generation changes mid-recognition', async () => {
|
||||
const useCase = createUseCase()
|
||||
let finish: ((value: { transcript: string; durationMs: number; cached: boolean }) => void) | undefined
|
||||
const state = useCase as unknown as {
|
||||
accountGeneration: number
|
||||
pipeline: { run: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
state.pipeline.run.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const listener = vi.fn()
|
||||
useCase.onTranscriptUpdate(listener)
|
||||
const pending = useCase.recognize({
|
||||
sessionId: 'fixture-contact',
|
||||
localId: 10,
|
||||
createTime: 1_785_895_201
|
||||
})
|
||||
await vi.waitFor(() => expect(state.pipeline.run).toHaveBeenCalledOnce())
|
||||
state.accountGeneration += 1
|
||||
finish?.({ transcript: '不应写入新账号', durationMs: 600, cached: false })
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ success: false, code: 'CANCELLED' })
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
await useCase.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user