"""独立 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