VetNet-LCPS / vetcopilot-backend /app /services /transcript_service.py
thomasm291678
VetCopilot-LCPS: LCPS 2.0 本地推理引擎替换 DeepSeek API
793e953
Raw History Blame Contribute Delete
17.7 kB
"""
转写服务
处理音频文件的语音转文字,支持:
- 双引擎(Whisper / 讯飞)切换
- 讯飞多模型(13 个)切换
- 实时进度上报
- 自动转换上传音频为讯飞要求的 PCM 16kHz 16bit mono 格式
"""
import json
import os
import subprocess
import wave
from typing import Any, Optional
from sqlalchemy.orm import Session
from app.config import settings
from app.services.asr import get_asr_engine, ASREngine, ASRResult
from app.services.mock_data import generate_mock_transcript
from app.services.progress_tracker import get_progress_tracker
from app.utils.logger import logger
from app.utils.exceptions import NotFoundException
class TranscriptService:
"""转写服务"""
def __init__(self, provider: str | None = None, model_id: str | None = None):
"""
初始化转写服务
Args:
provider: ASR 引擎类型,不传则使用默认配置
model_id: 讯飞模型 ID
"""
self._provider = provider or settings.ASR_PROVIDER
self._model_id = model_id or getattr(settings, "XUNFEI_MODEL", "realtime_llm")
self._engine: ASREngine | None = None
self._mock_enabled = settings.MOCK_ENABLED or not settings.TRANSCRIPTION_ENABLED
@property
def provider(self) -> str:
return self._provider
@property
def model_id(self) -> str:
return self._model_id
def get_engine(self) -> ASREngine:
"""获取当前 ASR 引擎(懒加载)"""
if self._engine is None:
self._engine = get_asr_engine(self._provider, self._model_id)
logger.info(
f"初始化 ASR 引擎: provider={self._provider}, model={self._model_id}"
)
return self._engine
def switch_provider(self, provider: str, model_id: Optional[str] = None) -> None:
"""
切换 ASR 引擎
Args:
provider: "whisper" | "xunfei"
model_id: 讯飞模型 ID(仅 provider="xunfei" 时使用)
"""
if provider not in ("whisper", "xunfei"):
raise ValueError(f"不支持的 ASR 引擎: {provider}")
self._provider = provider
if model_id:
self._model_id = model_id
self._engine = None # 强制重新初始化
logger.info(f"切换 ASR 引擎: provider={provider}, model={self._model_id}")
@staticmethod
def _resolve_user_engine(user_settings: Any | None, fallback_provider: str, fallback_model: str) -> tuple[str, str]:
"""根据 user_settings 解析当前用户实际使用的引擎与模型
优先级: 用户设置 > 全局环境变量默认值
返回 (provider, model_id)
"""
if user_settings is None:
logger.info(f"ASR引擎选择: 用户无设置记录,使用全局默认 provider={fallback_provider}, model={fallback_model}")
return fallback_provider, fallback_model
# 显式读取用户偏好,空字符串也视为未设置
user_provider = (user_settings.asr_provider or "").strip()
user_model = (getattr(user_settings, "asr_model", "") or "").strip()
provider = user_provider if user_provider else fallback_provider
model_id = user_model if user_model else fallback_model
logger.info(
f"ASR引擎选择: user_provider='{user_provider}', user_model='{user_model}' "
f"→ 实际使用 provider={provider}, model={model_id}"
)
return provider, model_id
def transcribe(
self,
audio_path: str,
*,
consultation_id: Optional[str] = None,
provider: Optional[str] = None,
model_id: Optional[str] = None,
**kwargs: Any,
) -> dict[str, Any]:
"""
转写音频文件(自动检测格式并转换为讯飞要求的 PCM 16kHz 16bit mono)
Args:
audio_path: 音频文件路径
consultation_id: 用于上报进度
provider: 临时覆盖 provider
model_id: 临时覆盖 model_id
**kwargs: 透传给引擎的额外参数
"""
# 如果指定了 provider/model_id,临时切换
original_provider, original_model = self._provider, self._model_id
if provider:
self._provider = provider
if model_id:
self._model_id = model_id
if self._provider != original_provider or self._model_id != original_model:
self._engine = None # 重置以加载新引擎
tracker = get_progress_tracker() if consultation_id else None
if tracker is not None:
tracker.init(consultation_id, engine=self._engine_name_for_log())
tracker.update(consultation_id, stage="preparing", percent=0,
message="正在准备转写…")
# 阶段1:音频格式转换 (0-20%)
if tracker is not None:
tracker.update(consultation_id, stage="converting", percent=5,
message="正在检测音频格式…")
converted_path = self._ensure_audio_format(audio_path)
if tracker is not None:
tracker.update(consultation_id, stage="uploading", percent=20,
message="音频格式转换完成,准备连接云端引擎…")
if self._mock_enabled:
logger.info(f"使用 Mock 转写: {audio_path}")
if tracker is not None:
tracker.update(consultation_id, stage="uploading", percent=30,
message="Mock 引擎初始化中…")
tracker.update(consultation_id, stage="transcribing", percent=60,
message="Mock 数据生成中…")
tracker.update(consultation_id, stage="done", percent=100,
message="Mock 转写完成")
return generate_mock_transcript()
# 阶段2:上传/连接讯飞 (20-40%) - 由引擎内部驱动
if tracker is not None:
tracker.update(consultation_id, stage="uploading", percent=20,
message="正在连接云端语音引擎…")
logger.info(
f"开始转写: {converted_path} provider={self._provider} "
f"model={self._model_id}"
)
try:
engine = self.get_engine()
# 注入进度回调:写到 ProgressTracker
def _on_progress(percent: int, stage: str) -> None:
if tracker is not None and consultation_id:
tracker.update(
consultation_id,
stage=stage,
percent=percent,
message=stage_msg(stage),
)
if hasattr(engine, "set_progress_callback"):
engine.set_progress_callback(_on_progress)
result: ASRResult = engine.transcribe(converted_path, **kwargs)
response = {
"segments": [
{
"speaker": seg.speaker,
"text": seg.text,
"start_time": seg.start_time,
"end_time": seg.end_time,
"confidence": seg.confidence,
}
for seg in result.segments
],
"full_text": result.full_text,
"confidence": result.confidence,
"audio_duration": result.audio_duration,
"engine": result.engine,
"word_count": result.word_count,
"speaker_turns": result.speaker_turns,
}
if tracker is not None and consultation_id:
tracker.update(consultation_id, stage="done", percent=100,
message="转写完成")
logger.info(
f"转写完成: {result.engine}, "
f"{len(result.segments)}段, {result.word_count}字, "
f"conf={result.confidence:.2f}"
)
return response
except Exception as e:
logger.error(f"转写失败: {e}")
if tracker is not None and consultation_id:
tracker.update(consultation_id, stage="error", percent=100,
message=f"转写失败: {e}", error=str(e))
raise
finally:
# 恢复原 provider/model
self._provider, self._model_id = original_provider, original_model
self._engine = None
def _engine_name_for_log(self) -> str:
return f"{self._provider}:{self._model_id}"
def _ensure_audio_format(self, audio_path: str) -> str:
"""
确保音频为讯飞要求的格式:PCM 16kHz 16bit mono WAV
"""
if not os.path.exists(audio_path):
raise FileNotFoundError(f"音频文件不存在: {audio_path}")
try:
with wave.open(audio_path, "rb") as w:
channels = w.getnchannels()
sample_width = w.getsampwidth()
frame_rate = w.getframerate()
needs_convert = (
channels != 1 or sample_width != 2 or frame_rate != 16000
)
if not needs_convert:
logger.info(
f"音频格式正确: {frame_rate}Hz/{sample_width*8}bit/{channels}ch, "
f"跳过转换"
)
return audio_path
logger.info(
f"音频需转换: {frame_rate}Hz/{sample_width*8}bit/{channels}ch → 16000Hz/16bit/1ch, "
f"path={audio_path}"
)
except Exception as exc:
logger.info(f"非 WAV 格式, 需转换: {audio_path} ({exc})")
needs_convert = True
base, _ = os.path.splitext(audio_path)
converted_path = f"{base}_16k.wav"
cmd = [
"ffmpeg", "-y", "-i", audio_path,
"-ac", "1", "-ar", "16000", "-sample_fmt", "s16",
converted_path,
]
try:
logger.info(f"FFmpeg 转换开始: {os.path.basename(audio_path)} -> {os.path.basename(converted_path)}")
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
logger.error(f"FFmpeg 转换失败: {result.stderr[:500]}")
raise RuntimeError("音频格式转换失败, 请确认已安装 FFmpeg")
converted_size = os.path.getsize(converted_path)
logger.info(
f"音频转换完成: {os.path.basename(converted_path)}, "
f"size={converted_size / 1024:.1f}KB"
)
return converted_path
except FileNotFoundError:
logger.error("FFmpeg 未安装")
raise RuntimeError(
"需要 FFmpeg 进行音频格式转换。"
"请安装 FFmpeg: https://ffmpeg.org/download.html"
)
except subprocess.TimeoutExpired:
logger.error(f"FFmpeg 转换超时: {audio_path}")
raise RuntimeError("音频转换超时(超120秒)")
# ============================================================
# DB 方法
# ============================================================
@staticmethod
def get_transcript(
db: Session,
consultation_id: str,
provider: Optional[str] = None,
model_id: Optional[str] = None,
) -> dict:
"""获取转写结果(旧路由兼容)"""
from app.models.consultation import Consultation, ConsultationStatus
from app.models.transcript import Transcript
from app.models.user_settings import UserSettings
consultation = TranscriptService._get_or_404(db, consultation_id)
# 解析用户偏好:优先使用用户设置,未设置时回退到全局默认
user_settings = db.query(UserSettings).filter(
UserSettings.user_id == consultation.doctor_id
).first()
provider, model_id = TranscriptService._resolve_user_engine(
user_settings,
fallback_provider=provider or settings.ASR_PROVIDER,
fallback_model=model_id or getattr(settings, "XUNFEI_MODEL", "realtime_llm"),
)
# 已转写 → 直接返回
if consultation.transcript and consultation.transcript.content:
content = json.loads(consultation.transcript.content)
TranscriptService._save_audio_attachment(db, consultation)
return {**content, "consultation_id": consultation_id}
# 未转写 → 如果有录音文件,触发转写
if consultation.audio_path and os.path.exists(consultation.audio_path):
if settings.MOCK_ENABLED or not settings.TRANSCRIPTION_ENABLED:
mock_data = generate_mock_transcript()
transcript = Transcript(
consultation_id=consultation_id,
content=json.dumps(mock_data, ensure_ascii=False),
confidence=mock_data.get("confidence", 0.0),
)
db.add(transcript)
consultation.status = ConsultationStatus.TRANSCRIBED
db.commit()
TranscriptService._save_audio_attachment(db, consultation)
return {**mock_data, "consultation_id": consultation_id}
try:
svc = TranscriptService(provider=provider, model_id=model_id)
result = svc.transcribe(
consultation.audio_path,
consultation_id=consultation_id,
)
transcript = Transcript(
consultation_id=consultation_id,
content=json.dumps(result, ensure_ascii=False),
confidence=result.get("confidence", 0.0),
)
db.add(transcript)
consultation.status = ConsultationStatus.TRANSCRIBED
db.commit()
TranscriptService._save_audio_attachment(db, consultation)
return {**result, "consultation_id": consultation_id}
except Exception as e:
logger.error(f"转写失败: {e}")
raise
raise NotFoundException("未找到录音文件, 请先上传音频")
@staticmethod
def extract_and_generate(db: Session, consultation_id: str) -> dict:
"""
LCPS 2.0: 病史提取 + SOAP 生成合并为一步。
直接调用 SoapService.generate() 完成从对话到完整 SOAP 的全流程。
"""
from app.services.soap_service import SoapService
return SoapService.generate(db, consultation_id)
@staticmethod
def get_extraction(db: Session, consultation_id: str) -> dict:
"""获取 AI 分析结果 — 直接读取已生成的 SOAP 病历"""
from app.services.soap_service import SoapService
return SoapService.get(db, consultation_id)
@staticmethod
def _get_or_404(db: Session, consultation_id: str):
from app.models.consultation import Consultation
consultation = db.query(Consultation).filter(
Consultation.id == consultation_id
).first()
if not consultation:
raise NotFoundException(f"问诊记录不存在: {consultation_id}")
return consultation
@staticmethod
def _save_audio_attachment(db: Session, consultation):
"""转录完成后将音频文件保存为附件(如尚未保存)"""
from app.models.attachment import Attachment
audio_path = consultation.audio_path
if not audio_path or not os.path.exists(audio_path):
return
# 检查是否已存在该音频文件的附件记录
existing = db.query(Attachment).filter(
Attachment.consultation_id == consultation.id,
Attachment.file_path == audio_path,
).first()
if existing:
return
# 根据扩展名确定 MIME 类型
ext = os.path.splitext(audio_path)[1].lower()
mime_map = {
".wav": "audio/wav",
".webm": "audio/webm",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".ogg": "audio/ogg",
".mp4": "audio/mp4",
}
mime_type = mime_map.get(ext, "audio/wav")
try:
file_size = os.path.getsize(audio_path)
attachment = Attachment(
consultation_id=consultation.id,
filename=os.path.basename(audio_path),
original_name=f"录音文件{ext}",
file_path=audio_path,
file_size=file_size,
mime_type=mime_type,
category="audio",
)
db.add(attachment)
db.commit()
logger.info(f"音频附件已保存: consultation={consultation.id}, path={audio_path}")
except Exception as e:
logger.warning(f"保存音频附件失败: {e}")
db.rollback()
def stage_msg(stage: str) -> str:
"""进度阶段 → 人类可读描述"""
return {
"preparing": "准备中…",
"converting": "音频格式转换中…",
"uploading": "音频上传/连接中…",
"transcribing": "语音转文字中…",
"extracting": "病史提取中…",
"generating": "AI 生成 SOAP 中…",
"done": "完成",
"error": "处理失败",
}.get(stage, "处理中…")