Files
zyt/TUICallKit-Vue3/tongji/utils/voiceParse.js
T
2026-06-09 10:16:38 +08:00

308 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 健康数据语音文本解析。
*
* 把语音识别得到的中文口语文本解析为结构化字段:
* - 血糖(空腹 / 餐后 / 其他)
* - 血压(高压 / 低压 / 西药 / 胰岛素)
* - 饮食(早 / 午 / 晚餐 + 备注)
* - 运动(类型 / 时长 / 强度)
*
* 兼容中文数字(六点五 / 一百二十 / 一百二)与阿拉伯数字(6.5 / 120)。
* 纯前端解析,无网络依赖,识别失败时返回空对象。
*/
const CN_DIGIT = {
: 0, : 0, : 1, : 1, : 1, : 2, : 2, : 2,
: 3, : 3, : 4, : 4, : 5, : 5, : 6, : 6,
: 7, : 7, : 8, : 8, : 9, : 9
}
const CN_UNIT = { : 10, : 10, : 100, : 100, : 1000, : 1000, : 10000, 亿: 100000000 }
/** 中文整数串 → 数字,例如 "一百二十" → 120、"八十" → 80、"十" → 10、"一百二" → 120 */
function cnIntToNumber(s) {
let total = 0
let section = 0
let number = 0
let hadUnit = false
let lastUnit = 0
let sawZeroAfterUnit = false
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) {
number = CN_DIGIT[ch]
if (number === 0) sawZeroAfterUnit = true
} else if (CN_UNIT[ch] !== undefined) {
hadUnit = true
const unit = CN_UNIT[ch]
lastUnit = unit
sawZeroAfterUnit = false
if (unit >= 10000) {
section = (section + number) * unit
total += section
section = 0
} else {
if (number === 0) number = 1 // 十 = 10
section += number * unit
}
number = 0
}
}
// 没有任何单位且为纯数字串(如 "一二零")时按位拼接更符合口语
if (!hadUnit && s.length > 1) {
let joined = ''
for (const ch of s) {
if (CN_DIGIT[ch] !== undefined) joined += CN_DIGIT[ch]
}
if (joined) return Number(joined)
}
// 口语省略尾部单位:"一百二" → 120、"一千五" → 1500(避开 "一百零五" 这类带零的)
if (number > 0 && lastUnit >= 100 && !sawZeroAfterUnit) {
number = number * (lastUnit / 10)
}
return total + section + number
}
/** 中文数字串(含小数点)→ 数字,例如 "六点五" → 6.5 */
function cnSeqToNumber(seq) {
if (seq.includes('点')) {
const parts = seq.split('点')
const intPart = parts[0]
const decPart = parts.slice(1).join('')
const intVal = intPart ? cnIntToNumber(intPart) : 0
let decStr = ''
for (const ch of decPart) {
if (CN_DIGIT[ch] !== undefined) decStr += CN_DIGIT[ch]
}
if (!decStr) return intVal
return Number(`${intVal}.${decStr}`)
}
return cnIntToNumber(seq)
}
/** 是否包含中文数字字符(用于过滤“点”“重点”等误匹配) */
function hasCnDigit(seq) {
for (const ch of seq) {
if (CN_DIGIT[ch] !== undefined || CN_UNIT[ch] !== undefined) return true
}
return false
}
/** 把文本中的中文数字段落替换为阿拉伯数字 */
export function normalizeNumbers(text) {
const raw = String(text || '')
return raw.replace(/[零〇一壹幺二贰两三叁四肆五伍六陆七柒八捌九玖十拾百佰千仟万亿点]+/g, (m) => {
if (!hasCnDigit(m)) return m
const n = cnSeqToNumber(m)
return Number.isFinite(n) ? String(n) : m
})
}
/** 在文本中找到关键词后紧随的第一个数字 */
function numAfter(text, keys) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
const rest = text.slice(i + k.length)
const m = rest.match(/-?\d+(?:\.\d+)?/)
if (m) return m[0]
}
}
return ''
}
/** 关键词后紧随的一段文本(截止到标点或下一个分隔关键词) */
function textAfter(text, keys, stopKeys = []) {
for (const k of keys) {
const i = text.indexOf(k)
if (i >= 0) {
let rest = text.slice(i + k.length)
// 去掉口语连接词
rest = rest.replace(/^[是为吃了喝了吃的喝的有打了用了::,,、。\s]+/, '')
let end = rest.length
const mStop = rest.match(/[,。.;!?\n]/)
if (mStop && mStop.index < end) end = mStop.index
for (const sk of stopKeys) {
const si = rest.indexOf(sk)
if (si >= 0 && si < end) end = si
}
const seg = rest.slice(0, end).trim()
if (seg) return seg
}
}
return ''
}
/**
* 解析血糖:返回 { fasting_blood_sugar, postprandial_blood_sugar, other_blood_sugar }
* 例:"空腹六点五餐后八点二" → { fasting:6.5, postprandial:8.2 }
*/
export function parseGlucose(raw) {
const t = normalizeNumbers(raw)
const result = {}
const fasting = numAfter(t, ['空腹'])
const post = numAfter(t, ['餐后', '饭后'])
const other = numAfter(t, ['其他', '随机', '睡前', '凌晨', '夜间', '晚上'])
if (fasting) result.fasting_blood_sugar = fasting
if (post) result.postprandial_blood_sugar = post
if (other) result.other_blood_sugar = other
// 没有关键词但只有一个数字 → 视为“其他血糖”
if (!fasting && !post && !other) {
const nums = t.match(/\d+(?:\.\d+)?/g)
if (nums && nums.length === 1) result.other_blood_sugar = nums[0]
}
return result
}
/**
* 解析血压:返回 { systolic_pressure, diastolic_pressure, western_medicine, insulin }
* 例:"高压一百二低压八十" → { systolic:120, diastolic:80 }
*/
export function parseBloodPressure(raw) {
const t = normalizeNumbers(raw)
const result = {}
let sys = numAfter(t, ['高压', '收缩压', '收缩'])
let dia = numAfter(t, ['低压', '舒张压', '舒张'])
if (!sys || !dia) {
const nums = (t.match(/\d{2,3}/g) || [])
.map(Number)
.filter((n) => n >= 30 && n <= 300)
if (!sys && !dia && nums.length >= 2) {
sys = String(nums[0])
dia = String(nums[1])
} else if (!sys && nums.length === 1 && nums[0] >= 90) {
sys = String(nums[0])
}
}
// 高压应不低于低压,顺序异常时交换
if (sys && dia && Number(sys) < Number(dia)) {
const tmp = sys
sys = dia
dia = tmp
}
if (sys) result.systolic_pressure = sys
if (dia) result.diastolic_pressure = dia
const insulin = textAfter(raw, ['胰岛素'], ['高压', '低压', '血压', '西药', '备注'])
if (insulin) result.insulin = insulin
const western = textAfter(raw, ['西药', '降糖药', '口服药'], ['胰岛素', '高压', '低压', '血压', '备注'])
if (western) result.western_medicine = western
return result
}
const DIET_MARKERS = [
{ keys: ['早餐', '早饭', '早上', '早点', '早晨'], field: 'breakfast_foods' },
{ keys: ['午餐', '午饭', '中午'], field: 'lunch_foods' },
{ keys: ['晚餐', '晚饭', '晚上', '夜宵', '夜里'], field: 'dinner_foods' }
]
function cleanFoodSeg(seg) {
return String(seg || '')
.replace(/^[是为吃了吃的喝了喝的有::,,、。\s]+/, '')
.replace(/[。.\s]+$/, '')
.trim()
}
/**
* 解析饮食:返回 { breakfast_foods, lunch_foods, dinner_foods, note }
* 例:"早餐鸡蛋粥中午米饭炒青菜晚上面条" → 分别归位
* 无餐别关键词时整句写入 note
*/
export function parseDiet(raw) {
const t = String(raw || '').trim()
const result = {}
const hits = []
DIET_MARKERS.forEach((m) => {
for (const k of m.keys) {
const idx = t.indexOf(k)
if (idx >= 0) {
hits.push({ idx, field: m.field, klen: k.length })
break
}
}
})
if (!hits.length) {
if (t) result.note = t
return result
}
hits.sort((a, b) => a.idx - b.idx)
hits.forEach((h, i) => {
const start = h.idx + h.klen
const end = i + 1 < hits.length ? hits[i + 1].idx : t.length
const seg = cleanFoodSeg(t.slice(start, end))
if (seg && !result[h.field]) result[h.field] = seg
})
// 第一个餐别关键词之前的内容作为备注
const head = cleanFoodSeg(t.slice(0, hits[0].idx))
if (head) result.note = head
return result
}
const EXERCISE_FILLERS = [
'今天', '我', '做了', '做', '进行了', '进行', '运动了', '运动', '锻炼了', '锻炼',
'了', '大概', '左右', '持续', '差不多', '一共', '总共', '的', '走了', '打了', '跑了'
]
/**
* 解析运动:返回 { exercise_type, duration, intensity }
* 例:"散步三十分钟中强度" → { type:'散步', duration:30, intensity:2 }
*/
export function parseExercise(raw) {
const t = normalizeNumbers(raw)
const result = {}
const durMatch = t.match(/(\d+(?:\.\d+)?)\s*(个小时|小时|钟头|时|分钟|分)/)
if (durMatch) {
const val = parseFloat(durMatch[1])
const isHour = /个小时|小时|钟头|时/.test(durMatch[2])
result.duration = String(Math.round(isHour ? val * 60 : val))
}
if (/高强度|剧烈|很累|大汗|气喘/.test(t)) result.intensity = 3
else if (/中强度|中等强度|中等|有点累|微微出汗|微汗/.test(t)) result.intensity = 2
else if (/低强度|轻松|轻微|溜达|缓慢/.test(t)) result.intensity = 1
// 运动类型:移除时长、强度、填充词后剩余文本
let type = t
if (durMatch) type = type.replace(durMatch[0], '')
type = type
.replace(/高强度|中强度|中等强度|低强度|剧烈|很累|大汗|气喘|有点累|微微出汗|微汗|轻松|轻微|缓慢/g, '')
.replace(/\d+(?:\.\d+)?/g, '')
.replace(/[,。.;、\s]+/g, '')
EXERCISE_FILLERS.forEach((f) => {
type = type.split(f).join('')
})
type = type.trim()
if (type && type.length <= 20) result.exercise_type = type
return result
}
const FIELD_LABELS = {
fasting_blood_sugar: '空腹',
postprandial_blood_sugar: '餐后',
other_blood_sugar: '其他血糖',
systolic_pressure: '高压',
diastolic_pressure: '低压',
western_medicine: '西药',
insulin: '胰岛素',
breakfast_foods: '早餐',
lunch_foods: '午餐',
dinner_foods: '晚餐',
note: '备注',
exercise_type: '运动',
duration: '时长',
intensity: '强度'
}
const INTENSITY_LABELS = { 1: '低强度', 2: '中强度', 3: '高强度' }
/** 把解析结果转成可读的反馈文案,例如 "空腹6.5 · 餐后8.2" */
export function summarizeParsed(parsed) {
const parts = []
Object.keys(parsed).forEach((k) => {
const label = FIELD_LABELS[k] || k
let val = parsed[k]
if (k === 'intensity') val = INTENSITY_LABELS[val] || val
parts.push(`${label}${val}`)
})
return parts.join(' · ')
}