feat(analysis): support voice transcriptions in ChatLab

适配导入文件中的语音转写内容,让 ChatLab 在语言分析、词频统计和口头禅分析中使用实际转写文字,而不是把语音时长标签当作文本。

识别并移除语音消息前的时长标签,保留转写内容;纯语音占位符继续从文本分析中排除。

统一桌面端、CLI Web 和 Web WASM 使用的文本清洗逻辑,并补充 ChatLab JSONL 导入、词频、语言偏好和口头禅分析测试。

测试使用脱敏后的合成消息,不包含个人聊天记录。
This commit is contained in:
babybitter authored and digua committed 2026-09-04 00:19:21 +08:00
1 parent f8e482e2a9
commit 0c34853066
9 files changed
+192 -13

No files matched your search

+7 -1
View File
@@ -24,4 +24,10 @@ export { POS_TAG_DEFINITIONS, MEANINGFUL_POS_TAGS } from './pos-tags'
export { CHINESE_STOPWORDS, ENGLISH_STOPWORDS, JAPANESE_STOPWORDS, getStopwords, isStopword } from './stopwords'
export { cleanText, isValidWord } from './text-utils'
export {
cleanText,
isMediaPlaceholderContent,
isValidWord,
stripMediaPlaceholders,
stripVoiceTranscriptionPrefix,
} from './text-utils'
+11 -1
View File
@@ -1,6 +1,6 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { cleanText } from './text-utils'
import { cleanText, isMediaPlaceholderContent, stripVoiceTranscriptionPrefix } from './text-utils'
describe('cleanText', () => {
const cases = [
@@ -22,4 +22,14 @@ describe('cleanText', () => {
assert.equal(cleanText(input), expected)
})
}
it('removes duration-bearing voice labels while retaining the transcription', () => {
assert.equal(cleanText('[语音 3秒] 这是测试语音内容。'), '这是测试语音内容')
assert.equal(stripVoiceTranscriptionPrefix('[语音 3秒] 这是测试语音内容。'), '这是测试语音内容。')
})
it('recognizes duration-bearing media-only content as a placeholder', () => {
assert.equal(isMediaPlaceholderContent('[语音 3秒]'), true)
assert.equal(isMediaPlaceholderContent('[语音 3秒] 有转写内容'), false)
})
})
+30 -2
View File
@@ -8,8 +8,21 @@ const PUNCTUATION_REGEX = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~,。!?、
const URL_REGEX = /https?:\/\/[^\s]+/g
const MENTION_REGEX = /@[^\s@]+/g
const PURE_NUMBER_REGEX = /^\d+$/
const SYSTEM_PLACEHOLDER_REGEX =
/\[(?:图片|视频|语音|文件|动画表情|表情|链接|位置|名片|红包|转账|音乐|Image|Video|Voice|File|Sticker|Link)\]/gi
const MEDIA_PLACEHOLDER_NAME_PATTERN =
'(?:图片|视频|语音|文件|动画表情|表情|链接|位置|地理位置|名片|红包|转账|音乐|Image|Photo|Video|Voice|Audio|File|Sticker|Link|Location)'
const MEDIA_PLACEHOLDER_DURATION_PATTERN = '(?:\\s+\\d+(?:\\.\\d+)?\\s*(?:秒|s))?'
const SYSTEM_PLACEHOLDER_REGEX = new RegExp(
`\\[${MEDIA_PLACEHOLDER_NAME_PATTERN}${MEDIA_PLACEHOLDER_DURATION_PATTERN}\\]`,
'gi'
)
const MEDIA_PLACEHOLDER_ONLY_REGEX = new RegExp(
`^\\[${MEDIA_PLACEHOLDER_NAME_PATTERN}${MEDIA_PLACEHOLDER_DURATION_PATTERN}\\]$`,
'i'
)
const VOICE_TRANSCRIPTION_PREFIX_REGEX = new RegExp(
`^\\s*\\[(?:语音|Voice|Audio)${MEDIA_PLACEHOLDER_DURATION_PATTERN}\\]\\s*`,
'i'
)
const BRACKET_EMOJI_PLACEHOLDER_REGEX =
/(?:\[|【)([\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Letter}\p{Number}_-]{1,16})(?:\]|】)/gu
const CJK_TEXT_REGEX = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/u
@@ -118,6 +131,21 @@ export function cleanText(text: string): string {
.trim()
}
/** Remove media placeholders, including duration-bearing labels such as `[语音 3秒]`. */
export function stripMediaPlaceholders(text: string): string {
return text.replace(SYSTEM_PLACEHOLDER_REGEX, ' ')
}
/** Return the transcribed text after a leading voice-message label. */
export function stripVoiceTranscriptionPrefix(text: string): string {
return text.replace(VOICE_TRANSCRIPTION_PREFIX_REGEX, '').trim()
}
/** Whether the complete value is a media placeholder rather than message text. */
export function isMediaPlaceholderContent(text: string): boolean {
return MEDIA_PLACEHOLDER_ONLY_REGEX.test(text.trim())
}
/**
* 判断是否为有效词语
*/
@@ -119,6 +119,25 @@ describe('getBrowserWordFrequency', () => {
assert.equal(result.uniqueWords, 2)
})
it('keeps voice transcription words but removes the duration label', () => {
const insert = raw.prepare('INSERT INTO message (id, sender_id, ts, type, content) VALUES (?, ?, ?, ?, ?)')
insert.run(6, 1, 600, 0, '[Voice 3s] sample transcript')
insert.run(7, 1, 700, 0, '[Voice 3s] sample transcript')
const result = getBrowserWordFrequency(database, {
sessionId: 'session-one',
locale: 'en-US',
topN: 20,
minCount: 2,
posFilterMode: 'all',
enableStopwords: false,
})
const words = result.words.map((word) => word.word)
assert.ok(words.includes('sample'))
assert.ok(!words.includes('voice'))
})
it('applies member, time, excluded-word, and excluded-message filters', () => {
const result = getBrowserWordFrequency(database, {
sessionId: 'session-one',
@@ -7,6 +7,7 @@
import type { TimeFilter } from '@openchatlab/shared-types'
import type { DatabaseAdapter } from '../../interfaces'
import { stripMediaPlaceholders, stripVoiceTranscriptionPrefix } from '../../nlp'
import { buildTimeFilter } from '../filters'
import { isSystemPlaceholderContent } from './text-filters'
@@ -46,7 +47,7 @@ const RE_PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~,。!?、;
const RE_PURE_NUMBER = /^\d+$/
function cleanTextForNlp(text: string): string {
return text
return stripMediaPlaceholders(text)
.replace(RE_URL, ' ')
.replace(RE_MENTION, ' ')
.replace(RE_EMOJI, ' ')
@@ -106,14 +107,15 @@ export function getLanguagePreferenceAnalysis(db: DatabaseAdapter, params: Langu
const memberMessages = new Map<number, { name: string; messages: string[] }>()
for (const row of rows) {
if (isSystemPlaceholderContent(row.content)) continue
const content = stripVoiceTranscriptionPrefix(row.content)
if (!content || isSystemPlaceholderContent(content)) continue
let entry = memberMessages.get(row.memberId)
if (!entry) {
entry = { name: row.name, messages: [] }
memberMessages.set(row.memberId, entry)
}
entry.messages.push(row.content)
entry.messages.push(content)
}
const isChinese = locale.startsWith('zh')
@@ -170,6 +170,30 @@ describe('getCatchphraseAnalysis', () => {
)
db.close()
})
it('shows voice transcriptions without the duration label', () => {
const db = createCatchphraseDb([
{ memberId: 1, content: '[语音 3秒] 测试转写内容', count: 3 },
{ memberId: 1, content: '[语音 2秒]', count: 5 },
])
const result = getCatchphraseAnalysis(db)
assert.deepEqual(result.members[0]?.catchphrases, [{ content: '测试转写内容', count: 3 }])
db.close()
})
it('aggregates the same transcription when voice durations differ', () => {
const db = createCatchphraseDb([
{ memberId: 1, content: '[语音 2秒] 同一句测试转写', count: 1 },
{ memberId: 1, content: '[语音 3秒] 同一句测试转写', count: 1 },
])
const result = getCatchphraseAnalysis(db)
assert.deepEqual(result.members[0]?.catchphrases, [{ content: '同一句测试转写', count: 2 }])
db.close()
})
})
describe('getLanguagePreferenceAnalysis', () => {
@@ -188,4 +212,28 @@ describe('getLanguagePreferenceAnalysis', () => {
assert.equal(result.members[0]?.totalMessages, 2)
assert.deepEqual(result.members[0]?.catchphrases, [{ content: 'noted', count: 2 }])
})
it('uses voice transcription text for language analysis instead of the duration label', () => {
const db = createRowsDb([
{ memberId: 1, name: 'Alice', content: '[Voice 3s] sample transcript' },
{ memberId: 1, name: 'Alice', content: '[Voice 3s] sample transcript' },
{ memberId: 1, name: 'Alice', content: '[语音 2秒]' },
{ memberId: 2, name: 'Bob', content: '普通文本' },
{ memberId: 2, name: 'Bob', content: '普通文本' },
])
const result = getLanguagePreferenceAnalysis(db, { locale: 'en-US' })
const alice = result.members.find((member: { name: string }) => member.name === 'Alice')
assert.equal(alice?.totalMessages, 2)
assert.deepEqual(alice?.catchphrases, [{ content: 'sample transcript', count: 2 }])
assert.equal(
alice?.topWords.some((word: { word: string }) => word.word === 'voice'),
false
)
assert.equal(
alice?.topWords.some((word: { word: string }) => word.word === 'sample'),
true
)
})
})
+29 -5
View File
@@ -4,9 +4,21 @@
import type { TimeFilter } from '@openchatlab/shared-types'
import type { DatabaseAdapter } from '../../interfaces'
import { stripVoiceTranscriptionPrefix } from '../../nlp'
import { buildTimeFilter } from '../filters'
import { isSystemPlaceholderContent } from './text-filters'
// Keep one-off voice transcription rows in the grouped query so JavaScript can
// normalize away duration labels before applying the repeated-phrase threshold.
const VOICE_TRANSCRIPTION_SQL = `
TRIM(msg.content) LIKE '[语音] %' OR
TRIM(msg.content) LIKE '[语音 %] %' OR
TRIM(msg.content) LIKE '[Voice] %' OR
TRIM(msg.content) LIKE '[Voice %] %' OR
TRIM(msg.content) LIKE '[Audio] %' OR
TRIM(msg.content) LIKE '[Audio %] %'
`
export interface CatchphraseItem {
content: string
count: number
@@ -48,7 +60,7 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter)
JOIN member m ON msg.sender_id = m.id
${whereClause}
GROUP BY m.id, TRIM(msg.content)
HAVING COUNT(*) >= 2
HAVING COUNT(*) >= 2 OR (${VOICE_TRANSCRIPTION_SQL})
ORDER BY m.id, count DESC
`
)
@@ -61,9 +73,11 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter)
}>
const memberMap = new Map<number, MemberCatchphrase>()
const phraseMaps = new Map<number, Map<string, CatchphraseItem>>()
for (const row of rows) {
if (isSystemPlaceholderContent(row.content)) continue
const content = stripVoiceTranscriptionPrefix(row.content)
if (!content || isSystemPlaceholderContent(content)) continue
if (!memberMap.has(row.memberId)) {
memberMap.set(row.memberId, {
@@ -74,13 +88,23 @@ export function getCatchphraseAnalysis(db: DatabaseAdapter, filter?: TimeFilter)
})
}
const member = memberMap.get(row.memberId)!
if (member.catchphrases.length < 100) {
member.catchphrases.push({ content: row.content, count: row.count })
let phrases = phraseMaps.get(row.memberId)
if (!phrases) {
phrases = new Map<string, CatchphraseItem>()
phraseMaps.set(row.memberId, phrases)
}
const existing = phrases.get(content)
if (existing) existing.count += Number(row.count)
else phrases.set(content, { content, count: Number(row.count) })
}
const members = Array.from(memberMap.values())
for (const member of members) {
member.catchphrases = [...(phraseMaps.get(member.memberId)?.values() ?? [])]
.filter((item) => item.count >= 2)
.sort((a, b) => b.count - a.count || a.content.localeCompare(b.content))
.slice(0, 100)
}
members.sort((a, b) => {
const countDifference = (b.catchphrases[0]?.count ?? 0) - (a.catchphrases[0]?.count ?? 0)
return countDifference || a.memberId - b.memberId
@@ -1,3 +1,5 @@
import { isMediaPlaceholderContent } from '../../nlp'
const SYSTEM_PLACEHOLDER_CONTENTS = new Set([
'[图片]',
'[视频]',
@@ -24,5 +26,6 @@ const SYSTEM_PLACEHOLDER_CONTENTS = new Set([
])
export function isSystemPlaceholderContent(content: string): boolean {
return SYSTEM_PLACEHOLDER_CONTENTS.has(content.trim())
const trimmed = content.trim()
return SYSTEM_PLACEHOLDER_CONTENTS.has(trimmed) || isMediaPlaceholderContent(trimmed)
}
@@ -84,6 +84,45 @@ test('ChatLab JSONL emits message batches and progress while reading', async ()
}
})
test('preserves a duration-bearing voice transcription in the parsed message content', async () => {
const root = makeTempDir()
const filePath = path.join(root, 'voice-transcription.jsonl')
const transcription = '[语音 3秒] 这是测试语音内容。'
fs.writeFileSync(
filePath,
[
JSON.stringify({
_type: 'header',
chatlab: { version: '0.0.2', exportedAt: 1711468800 },
meta: { name: 'Voice transcription', platform: 'douyin', type: 'private' },
}),
JSON.stringify({ _type: 'member', platformId: 'member-1', accountName: 'Alice' }),
JSON.stringify({
_type: 'message',
sender: 'member-1',
accountName: 'Alice',
timestamp: 1711468800,
type: MessageType.TEXT,
content: transcription,
platformMessageId: 'voice-1',
}),
].join('\n') + '\n',
'utf-8'
)
try {
const messages: Array<{ type: number; content: string | null }> = []
for await (const event of parseFileWithFormat('chatlab-jsonl', { filePath })) {
if (event.type === 'messages') {
messages.push(...event.data.map(({ type, content }) => ({ type, content })))
}
}
assert.deepEqual(messages, [{ type: MessageType.TEXT, content: transcription }])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('directory entry detection accepts a top-level ChatLab JSONL file', () => {
const root = makeTempDir()
const filePath = path.join(root, 'chat.jsonl')