ASR-demo/realtime_asr_optimization_demo/speaker_assembler.py

187 lines
8.7 KiB
Python
Raw Normal View History

2026-09-10 05:47:09 +00:00
"""独立 Demo 使用的有状态原始片段与展示区块组装器。"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
import math
from typing import Any
TRUSTED_CONFIDENCE = 0.6
def _as_float(value: Any, default: float = 0.0) -> float:
try:
parsed = float(value)
return parsed if math.isfinite(parsed) else default
except (TypeError, ValueError, OverflowError):
return default
def _as_int(value: Any, default: int = -1) -> int:
try:
return int(value)
except (TypeError, ValueError, OverflowError):
return default
def _without_embeddings(payload: dict[str, Any]) -> dict[str, Any]:
"""阻止原始声纹向量进入 Demo 的持久化状态。"""
sanitized: dict[str, Any] = {}
for key, value in payload.items():
lowered = str(key).lower()
if "embedding" in lowered or lowered in {"_chunks", "chunks", "_chunk_embeddings"}:
continue
sanitized[key] = deepcopy(value)
return sanitized
def _is_trusted(segment: dict[str, Any]) -> bool:
"""在展示身份稳定前,要求存在独立的说话人证据。"""
evidence = str(segment.get("speaker_evidence") or "pending").lower()
return (
_as_int(segment.get("speaker_id")) >= 0
and evidence in {"fresh", "confirmed"}
and _as_float(segment.get("speaker_confidence")) >= TRUSTED_CONFIDENCE
and segment.get("speaker_strategy") not in {"short_attach", "embedding_attach"}
)
def _speaker_identity(segment: dict[str, Any]) -> tuple[Any, ...]:
"""实名身份优先于匿名簇,防止同簇弱标签或不同实名被合并。"""
for key in ("user_id", "registry_speaker_id"):
if segment.get(key) not in (None, ""):
return (key, str(segment[key]))
return ("cluster", segment.get("speaker_id"), segment.get("speaker_name", ""))
@dataclass
class SegmentAssembler:
"""保存可幂等更新的原始片段,并按时间顺序派生展示区块。"""
segments: dict[int, dict[str, Any]] = field(default_factory=dict)
def apply_sentence(self, incoming: dict[str, Any]) -> dict[str, Any]:
"""写入或更新一条中间或最终句子,并保护不可靠的短身份名称。"""
sentence_id = _as_int(incoming.get("sentence_id"), 0)
previous = self.segments.get(sentence_id)
segment = dict(previous or {})
segment.update(_without_embeddings(incoming))
# 文本重发不能抹掉已到达的声纹更新,也不能把 final 回滚成 partial。
if previous and previous.get("sentence_type") == 1 and incoming.get("sentence_type") == 0:
return deepcopy(previous)
segment["sentence_id"] = sentence_id
segment["sentence"] = str(segment.get("sentence") or segment.get("text") or "").strip()
segment["sentence_type"] = _as_int(segment.get("sentence_type"), 0)
segment["start_time"] = _as_float(segment.get("start_time"))
segment["end_time"] = _as_float(segment.get("end_time"))
segment["speaker_id"] = _as_int(segment.get("speaker_id"))
segment["speaker_name"] = str(segment.get("speaker_name") or "")
segment["speaker_evidence"] = str(segment.get("speaker_evidence") or "pending")
segment["speaker_confidence"] = _as_float(segment.get("speaker_confidence"))
strategy = str(segment.get("speaker_strategy") or "")
if strategy in {"short_attach", "embedding_attach"}:
# 继承而来的短名称不属于新的可靠证据,必须继续保持 pending 状态,
# 防止前一个片段的身份错误污染当前展示结果。
segment["speaker_id"] = -1
segment["speaker_name"] = ""
segment["speaker_evidence"] = "pending"
segment["speaker_confidence"] = 0.0
segment["speaker_status"] = "inherited_rejected"
segment["speaker_reason"] = "缺少当前片段的独立声纹证据"
segment.pop("user_id", None)
segment.pop("registry_speaker_id", None)
if previous is not None:
segment["revision_count"] = int(previous.get("revision_count", 0)) + (
1 if segment["sentence"] != previous.get("sentence") else 0
)
else:
segment["revision_count"] = 0
self.segments[sentence_id] = segment
return deepcopy(segment)
def apply_speaker_update(self, update: dict[str, Any]) -> dict[str, Any] | None:
"""只将明确标记为新鲜或已确认的更新应用到已有片段。"""
sentence_id = _as_int(update.get("sentence_id"), -1)
current = self.segments.get(sentence_id)
if current is None:
return None
candidate = dict(current)
# 新身份的实名字段必须来自本次证据,不能沿用同片段旧识别的人员 ID。
candidate.pop("user_id", None)
candidate.pop("registry_speaker_id", None)
# 说话人响应只能更新身份字段,不能篡改已确认的文本和 ASR 时间范围。
candidate.update({
key: value for key, value in _without_embeddings(update).items()
if key.startswith("speaker_") or key in {"user_id", "registry_speaker_id"}
})
candidate["speaker_id"] = _as_int(candidate.get("speaker_id"))
candidate["speaker_evidence"] = str(update.get("speaker_evidence") or "pending")
candidate["speaker_confidence"] = _as_float(update.get("speaker_confidence"))
if not _is_trusted(candidate):
# 保留拒绝原因供前端诊断,但绝不把不可靠身份带入展示或历史状态。
candidate.update(speaker_id=-1, speaker_name="", speaker_evidence="pending", speaker_confidence=0.0)
candidate.pop("user_id", None)
candidate.pop("registry_speaker_id", None)
if update.get("speaker_status") not in {
"queued", "processing", "waiting_final", "disabled", "insufficient_audio",
"service_unavailable", "service_error", "no_embedding", "evidence_rejected",
}:
candidate["speaker_status"] = "evidence_rejected"
candidate["speaker_reason"] = "声纹结果缺少新鲜证据或置信度不足"
else:
candidate["speaker_status"] = "confirmed"
candidate["speaker_reason"] = "当前片段声纹已确认"
self.segments[sentence_id] = candidate
return deepcopy(candidate)
def raw_snapshot(self) -> list[dict[str, Any]]:
"""返回按时间、再按句子 ID 排序后的全部原始片段。"""
return [
deepcopy(segment)
for segment in sorted(self.segments.values(), key=lambda item: (item["start_time"], item["sentence_id"]))
]
def display_blocks(self, merge_adjacent: bool = True) -> list[dict[str, Any]]:
"""生成展示区块,同时保持非相邻说话人轮次的原始顺序。"""
blocks: list[dict[str, Any]] = []
for segment in self.raw_snapshot():
trusted = _is_trusted(segment)
identity_key = _speaker_identity(segment) if trusted else ("pending", segment["sentence_id"])
if (
merge_adjacent
and blocks
and trusted
and blocks[-1].get("identity_key") == identity_key
):
block = blocks[-1]
block["sentence"] = f'{block["sentence"]} {segment["sentence"]}'.strip()
block["end_time"] = max(block["end_time"], segment["end_time"])
block["segment_ids"].append(segment["sentence_id"])
block["sentence_type"] = min(block["sentence_type"], segment["sentence_type"])
continue
blocks.append(
{
"block_id": f"block-{segment['sentence_id']}",
"sentence": segment["sentence"],
"start_time": segment["start_time"],
"end_time": segment["end_time"],
"segment_ids": [segment["sentence_id"]],
"speaker_id": segment["speaker_id"] if trusted else -1,
"speaker_name": segment["speaker_name"] if trusted else "",
"speaker_evidence": "confirmed" if trusted else "pending",
"speaker_status": segment.get("speaker_status", "pending"),
"speaker_reason": segment.get("speaker_reason", ""),
"speaker_confidence": segment.get("speaker_confidence", 0.0),
"sentence_type": segment["sentence_type"],
"identity_key": identity_key,
}
)
for block in blocks:
block.pop("identity_key", None)
return blocks