fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
202 lines
6.5 KiB
Python
202 lines
6.5 KiB
Python
import json
|
|
import re
|
|
import uuid
|
|
from typing import Dict, Optional
|
|
|
|
|
|
LIVE_STT_SESSION_PREFIX = "stt_live_session:"
|
|
LIVE_STT_SESSION_TTL_SECONDS = 15 * 60
|
|
LIVE_STT_MUTABLE_TAIL_WORDS = 8
|
|
LIVE_STT_SILENCE_MUTABLE_TAIL_WORDS = 2
|
|
LIVE_STT_MIN_COMMITTED_OVERLAP_WORDS = 2
|
|
|
|
|
|
def normalize_transcript_text(text: str) -> str:
|
|
return " ".join((text or "").split()).strip()
|
|
|
|
|
|
def join_transcript_parts(*parts: str) -> str:
|
|
return " ".join(part for part in map(normalize_transcript_text, parts) if part)
|
|
|
|
|
|
def _normalize_word(word: str) -> str:
|
|
normalized = re.sub(r"[^\w]+", "", word.casefold(), flags=re.UNICODE)
|
|
return normalized or word.casefold()
|
|
|
|
|
|
def _split_words(text: str) -> list[str]:
|
|
normalized = normalize_transcript_text(text)
|
|
return normalized.split() if normalized else []
|
|
|
|
|
|
def _common_prefix_length(left_words: list[str], right_words: list[str]) -> int:
|
|
max_index = min(len(left_words), len(right_words))
|
|
prefix_length = 0
|
|
for index in range(max_index):
|
|
if _normalize_word(left_words[index]) != _normalize_word(right_words[index]):
|
|
break
|
|
prefix_length += 1
|
|
return prefix_length
|
|
|
|
|
|
def _find_suffix_prefix_overlap(
|
|
left_words: list[str], right_words: list[str], min_overlap: int
|
|
) -> int:
|
|
max_overlap = min(len(left_words), len(right_words))
|
|
if max_overlap < min_overlap:
|
|
return 0
|
|
|
|
left_keys = [_normalize_word(word) for word in left_words]
|
|
right_keys = [_normalize_word(word) for word in right_words]
|
|
|
|
for overlap_size in range(max_overlap, min_overlap - 1, -1):
|
|
if left_keys[-overlap_size:] == right_keys[:overlap_size]:
|
|
return overlap_size
|
|
return 0
|
|
|
|
|
|
def strip_committed_prefix(committed_text: str, hypothesis_text: str) -> str:
|
|
committed_words = _split_words(committed_text)
|
|
hypothesis_words = _split_words(hypothesis_text)
|
|
if not committed_words or not hypothesis_words:
|
|
return normalize_transcript_text(hypothesis_text)
|
|
|
|
full_prefix_length = _common_prefix_length(committed_words, hypothesis_words)
|
|
if full_prefix_length == len(committed_words):
|
|
return " ".join(hypothesis_words[full_prefix_length:])
|
|
|
|
overlap_size = _find_suffix_prefix_overlap(
|
|
committed_words,
|
|
hypothesis_words,
|
|
LIVE_STT_MIN_COMMITTED_OVERLAP_WORDS,
|
|
)
|
|
if overlap_size:
|
|
return " ".join(hypothesis_words[overlap_size:])
|
|
return " ".join(hypothesis_words)
|
|
|
|
|
|
def _calculate_commit_count(
|
|
previous_hypothesis: str, current_hypothesis: str, is_silence: bool
|
|
) -> int:
|
|
previous_words = _split_words(previous_hypothesis)
|
|
current_words = _split_words(current_hypothesis)
|
|
if not current_words:
|
|
return 0
|
|
|
|
if not previous_words:
|
|
if is_silence:
|
|
return max(0, len(current_words) - LIVE_STT_SILENCE_MUTABLE_TAIL_WORDS)
|
|
return 0
|
|
|
|
stable_prefix_length = _common_prefix_length(previous_words, current_words)
|
|
if not stable_prefix_length:
|
|
return 0
|
|
|
|
mutable_tail_words = (
|
|
LIVE_STT_SILENCE_MUTABLE_TAIL_WORDS
|
|
if is_silence
|
|
else LIVE_STT_MUTABLE_TAIL_WORDS
|
|
)
|
|
max_committable_by_tail = max(0, len(current_words) - mutable_tail_words)
|
|
return min(stable_prefix_length, max_committable_by_tail)
|
|
|
|
|
|
def create_live_stt_session(
|
|
user: str, language: Optional[str] = None
|
|
) -> Dict[str, object]:
|
|
return {
|
|
"session_id": str(uuid.uuid4()),
|
|
"user": user,
|
|
"language": language,
|
|
"committed_text": "",
|
|
"mutable_text": "",
|
|
"previous_hypothesis": "",
|
|
"latest_hypothesis": "",
|
|
"last_chunk_index": -1,
|
|
}
|
|
|
|
|
|
def get_live_stt_session_key(session_id: str) -> str:
|
|
return f"{LIVE_STT_SESSION_PREFIX}{session_id}"
|
|
|
|
|
|
def save_live_stt_session(redis_client, session_state: Dict[str, object]) -> None:
|
|
redis_client.setex(
|
|
get_live_stt_session_key(str(session_state["session_id"])),
|
|
LIVE_STT_SESSION_TTL_SECONDS,
|
|
json.dumps(session_state),
|
|
)
|
|
|
|
|
|
def load_live_stt_session(redis_client, session_id: str) -> Optional[Dict[str, object]]:
|
|
raw_session = redis_client.get(get_live_stt_session_key(session_id))
|
|
if not raw_session:
|
|
return None
|
|
if isinstance(raw_session, bytes):
|
|
raw_session = raw_session.decode("utf-8")
|
|
return json.loads(raw_session)
|
|
|
|
|
|
def delete_live_stt_session(redis_client, session_id: str) -> None:
|
|
redis_client.delete(get_live_stt_session_key(session_id))
|
|
|
|
|
|
def apply_live_stt_hypothesis(
|
|
session_state: Dict[str, object],
|
|
hypothesis_text: str,
|
|
chunk_index: int,
|
|
is_silence: bool = False,
|
|
) -> Dict[str, object]:
|
|
last_chunk_index = int(session_state.get("last_chunk_index", -1))
|
|
if chunk_index < 0:
|
|
raise ValueError("chunk_index must be non-negative")
|
|
if chunk_index < last_chunk_index:
|
|
raise ValueError("chunk_index is older than the last processed chunk")
|
|
if chunk_index == last_chunk_index:
|
|
return session_state
|
|
|
|
committed_text = normalize_transcript_text(str(session_state.get("committed_text", "")))
|
|
previous_hypothesis = normalize_transcript_text(
|
|
str(session_state.get("latest_hypothesis", ""))
|
|
)
|
|
current_hypothesis = strip_committed_prefix(committed_text, hypothesis_text)
|
|
|
|
if not current_hypothesis and is_silence and previous_hypothesis:
|
|
committed_text = join_transcript_parts(committed_text, previous_hypothesis)
|
|
previous_hypothesis = ""
|
|
|
|
commit_count = _calculate_commit_count(
|
|
previous_hypothesis,
|
|
current_hypothesis,
|
|
is_silence=is_silence,
|
|
)
|
|
current_words = _split_words(current_hypothesis)
|
|
|
|
if commit_count:
|
|
committed_text = join_transcript_parts(
|
|
committed_text,
|
|
" ".join(current_words[:commit_count]),
|
|
)
|
|
current_hypothesis = " ".join(current_words[commit_count:])
|
|
|
|
session_state["committed_text"] = committed_text
|
|
session_state["mutable_text"] = normalize_transcript_text(current_hypothesis)
|
|
session_state["previous_hypothesis"] = previous_hypothesis
|
|
session_state["latest_hypothesis"] = normalize_transcript_text(current_hypothesis)
|
|
session_state["last_chunk_index"] = chunk_index
|
|
return session_state
|
|
|
|
|
|
def get_live_stt_transcript_text(session_state: Dict[str, object]) -> str:
|
|
return join_transcript_parts(
|
|
str(session_state.get("committed_text", "")),
|
|
str(session_state.get("mutable_text", "")),
|
|
)
|
|
|
|
|
|
def finalize_live_stt_session(session_state: Dict[str, object]) -> str:
|
|
return join_transcript_parts(
|
|
str(session_state.get("committed_text", "")),
|
|
str(session_state.get("latest_hypothesis", "")),
|
|
)
|