Download vetcopilot-backend/app/services/transcript_service.py from WWsCa/VetNet-LCPS: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-backend/app/services/transcript_service.py
- Command line
-
hf download hf://WWsCa/VetNet-LCPS/vetcopilot-backend/app/services/transcript_service.py
-
curl -L -o transcript_service.py https://huggingface.co/WWsCa/VetNet-LCPS/resolve/main/vetcopilot-backend/app/services/transcript_service.py
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 | |
| def provider(self) -> str: | |
| return self._provider | |
| 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}") | |
| 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 方法 | |
| # ============================================================ | |
| 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("未找到录音文件, 请先上传音频") | |
| 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) | |
| def get_extraction(db: Session, consultation_id: str) -> dict: | |
| """获取 AI 分析结果 — 直接读取已生成的 SOAP 病历""" | |
| from app.services.soap_service import SoapService | |
| return SoapService.get(db, consultation_id) | |
| 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 | |
| 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, "处理中…") |