2026-04-23 14:24:20 +08:00
|
|
|
|
"""语音确认(ASR/解析/审计)的终端 loguru 行 + 每手术 TSV 落盘,与 `consumption_tsv_log` 并列。"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import re
|
|
|
|
|
|
import threading
|
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
|
|
|
|
|
|
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
|
|
from app.config import Settings
|
|
|
|
|
|
|
|
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
HEADER = (
|
|
|
|
|
|
"时间戳(ISO,UTC)\t来源\t状态\tconfirmation_id\tasr/识别文本\t"
|
|
|
|
|
|
"resolved_label\trejected\terror\taudio_object_key\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ts_iso_utc() -> str:
|
|
|
|
|
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _encode_cell(value: str) -> str:
|
|
|
|
|
|
return (value or "").replace("\r", " ").replace("\n", " ").replace("\t", " ")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log_tz_info(settings: Settings) -> object:
|
|
|
|
|
|
raw = (settings.consumption_log_timezone or "").strip()
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
lt = datetime.now().astimezone().tzinfo
|
|
|
|
|
|
return lt if lt is not None else timezone.utc
|
|
|
|
|
|
try:
|
|
|
|
|
|
return ZoneInfo(raw)
|
|
|
|
|
|
except ZoneInfoNotFoundError:
|
|
|
|
|
|
return timezone.utc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ts_local_for_display(settings: Settings) -> str:
|
|
|
|
|
|
tz = _log_tz_info(settings)
|
|
|
|
|
|
return datetime.now(tz).isoformat(timespec="milliseconds")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _safe_surgery_path_segment(surgery_id: str) -> str:
|
|
|
|
|
|
s = (surgery_id or "unknown").strip() or "unknown"
|
|
|
|
|
|
s = re.sub(r"[^\w\-.@]", "_", s)
|
|
|
|
|
|
return s[:200] if len(s) > 200 else s
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolved_voice_log_path(surgery_id: str, settings: Settings) -> Path:
|
|
|
|
|
|
raw = (settings.voice_file_log_path or "logs/voice_{surgery_id}.txt").strip()
|
|
|
|
|
|
safe = _safe_surgery_path_segment(surgery_id)
|
|
|
|
|
|
if "{surgery_id}" in raw:
|
|
|
|
|
|
raw = raw.replace("{surgery_id}", safe)
|
|
|
|
|
|
else:
|
|
|
|
|
|
p0 = Path(raw)
|
|
|
|
|
|
if p0.suffix:
|
|
|
|
|
|
raw = str(p0.with_name(f"{p0.stem}_{safe}{p0.suffix}"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
raw = f"{raw.rstrip('/')}_{safe}.txt"
|
|
|
|
|
|
p = Path(raw).expanduser()
|
|
|
|
|
|
if not p.is_absolute():
|
|
|
|
|
|
p = Path.cwd() / p
|
|
|
|
|
|
return p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def init_voice_log_file(surgery_id: str, settings: Settings) -> None:
|
|
|
|
|
|
"""与 `init_consumption_log_file` 同生命周期:`start_surgery` 时截断并写表头。"""
|
|
|
|
|
|
if not settings.voice_file_log_enabled:
|
|
|
|
|
|
return
|
|
|
|
|
|
path = resolved_voice_log_path(surgery_id, settings)
|
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
with _lock:
|
|
|
|
|
|
with path.open("w", encoding="utf-8") as f:
|
|
|
|
|
|
f.write(HEADER)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def append_voice_tsv_line(surgery_id: str, line: str, settings: Settings) -> None:
|
|
|
|
|
|
if not settings.voice_file_log_enabled:
|
|
|
|
|
|
return
|
|
|
|
|
|
path = resolved_voice_log_path(surgery_id, settings)
|
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
with _lock:
|
|
|
|
|
|
with path.open("a", encoding="utf-8") as f:
|
|
|
|
|
|
f.write(line)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 20:42:21 +08:00
|
|
|
|
class VoiceTextLogWriter:
|
|
|
|
|
|
"""注入式 voice 日志写入器,封装 `init_file` / `emit_event`。
|
|
|
|
|
|
|
|
|
|
|
|
行为等价于模块级函数;保留模块级函数以兼容既有调用点。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, app_settings: Settings) -> None:
|
|
|
|
|
|
self._s = app_settings
|
|
|
|
|
|
|
|
|
|
|
|
def init_file(self, surgery_id: str) -> None:
|
|
|
|
|
|
init_voice_log_file(surgery_id, self._s)
|
|
|
|
|
|
|
|
|
|
|
|
def emit_event(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
surgery_id: str,
|
|
|
|
|
|
source: str,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
confirmation_id: str,
|
|
|
|
|
|
asr_text: str | None = None,
|
|
|
|
|
|
resolved_label: str | None = None,
|
|
|
|
|
|
rejected: str | bool | None = None,
|
|
|
|
|
|
error_message: str | None = None,
|
|
|
|
|
|
audio_object_key: str | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
emit_voice_event(
|
|
|
|
|
|
self._s,
|
|
|
|
|
|
surgery_id=surgery_id,
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
confirmation_id=confirmation_id,
|
|
|
|
|
|
asr_text=asr_text,
|
|
|
|
|
|
resolved_label=resolved_label,
|
|
|
|
|
|
rejected=rejected,
|
|
|
|
|
|
error_message=error_message,
|
|
|
|
|
|
audio_object_key=audio_object_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 14:24:20 +08:00
|
|
|
|
def emit_voice_event(
|
|
|
|
|
|
settings: Settings,
|
|
|
|
|
|
*,
|
|
|
|
|
|
surgery_id: str,
|
|
|
|
|
|
source: str,
|
|
|
|
|
|
status: str,
|
|
|
|
|
|
confirmation_id: str,
|
|
|
|
|
|
asr_text: str | None = None,
|
|
|
|
|
|
resolved_label: str | None = None,
|
|
|
|
|
|
rejected: str | bool | None = None,
|
|
|
|
|
|
error_message: str | None = None,
|
|
|
|
|
|
audio_object_key: str | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
终端:单条可 grep 的 VoiceConfirm 行;文件:TSV 一行(与启用的 `voice_file_log_enabled` 一致)。
|
|
|
|
|
|
|
|
|
|
|
|
:param source: `wav` | `text` | `n/a`
|
|
|
|
|
|
:param status: 与审计 `status` 或 `minio_not_configured` 等说明型状态一致
|
|
|
|
|
|
"""
|
|
|
|
|
|
rj: str
|
|
|
|
|
|
if rejected is None:
|
|
|
|
|
|
rj = ""
|
|
|
|
|
|
elif isinstance(rejected, bool):
|
|
|
|
|
|
rj = "true" if rejected else "false"
|
|
|
|
|
|
else:
|
|
|
|
|
|
rj = str(rejected)
|
|
|
|
|
|
|
|
|
|
|
|
ts_utc = _ts_iso_utc()
|
|
|
|
|
|
local_hint = _ts_local_for_display(settings)
|
|
|
|
|
|
if status in ("recognized", "rejected"):
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"VoiceConfirm local_ts={!r} surgery_id={} source={} status={} "
|
|
|
|
|
|
"confirmation_id={} asr_text={!r} resolved_label={!r} rejected={} "
|
|
|
|
|
|
"error={!r} audio_key={!r}",
|
|
|
|
|
|
local_hint,
|
|
|
|
|
|
surgery_id,
|
|
|
|
|
|
source,
|
|
|
|
|
|
status,
|
|
|
|
|
|
confirmation_id,
|
|
|
|
|
|
asr_text,
|
|
|
|
|
|
resolved_label,
|
|
|
|
|
|
rj,
|
|
|
|
|
|
error_message,
|
|
|
|
|
|
audio_object_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"VoiceConfirm local_ts={!r} surgery_id={} source={} status={} "
|
|
|
|
|
|
"confirmation_id={} asr_text={!r} resolved_label={!r} rejected={} "
|
|
|
|
|
|
"error={!r} audio_key={!r}",
|
|
|
|
|
|
local_hint,
|
|
|
|
|
|
surgery_id,
|
|
|
|
|
|
source,
|
|
|
|
|
|
status,
|
|
|
|
|
|
confirmation_id,
|
|
|
|
|
|
asr_text,
|
|
|
|
|
|
resolved_label,
|
|
|
|
|
|
rj,
|
|
|
|
|
|
error_message,
|
|
|
|
|
|
audio_object_key,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if not settings.voice_file_log_enabled:
|
|
|
|
|
|
return
|
|
|
|
|
|
row = [
|
|
|
|
|
|
_encode_cell(ts_utc),
|
|
|
|
|
|
_encode_cell(source),
|
|
|
|
|
|
_encode_cell(status),
|
|
|
|
|
|
_encode_cell(confirmation_id),
|
|
|
|
|
|
_encode_cell("" if asr_text is None else asr_text),
|
|
|
|
|
|
_encode_cell("" if resolved_label is None else resolved_label),
|
|
|
|
|
|
_encode_cell(rj),
|
|
|
|
|
|
_encode_cell("" if error_message is None else error_message),
|
|
|
|
|
|
_encode_cell("" if audio_object_key is None else audio_object_key),
|
|
|
|
|
|
]
|
|
|
|
|
|
line = "\t".join(row) + "\n"
|
|
|
|
|
|
append_voice_tsv_line(surgery_id, line, settings)
|