187 lines
8.7 KiB
Python
187 lines
8.7 KiB
Python
"""独立 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
|