mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
feat(analysis): support voice transcriptions in ChatLab
适配导入文件中的语音转写内容,让 ChatLab 在语言分析、词频统计和口头禅分析中使用实际转写文字,而不是把语音时长标签当作文本。 识别并移除语音消息前的时长标签,保留转写内容;纯语音占位符继续从文本分析中排除。 统一桌面端、CLI Web 和 Web WASM 使用的文本清洗逻辑,并补充 ChatLab JSONL 导入、词频、语言偏好和口头禅分析测试。 测试使用脱敏后的合成消息,不包含个人聊天记录。
This commit is contained in:
1 parent
f8e482e2a9
commit
0c34853066
9 files changed
+192
-13
No files matched your search
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user