Files
call-center/scripts/materialize_ivr_prompts.py
T

253 lines
9.3 KiB
Python

from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import shutil
import sqlite3
import sys
import tempfile
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
from services.ai_voice_runtime_service.audiosocket import pcm16le_to_wav_bytes, resample_pcm16le
from services.ai_voice_runtime_service.providers.tts import build_tts_provider
def _default_provider_name() -> str:
return os.getenv("AI_VOICE_TTS_PROVIDER", "yandex").strip() or "yandex"
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Bulk-materialize IVR prompt WAV files from a flow JSON document or IVR SQLite DB.",
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--flow-json-file", help="Path to a flow JSON document.")
source.add_argument("--sqlite-db", help="Path to SQLite DB containing ivr_flows.")
parser.add_argument("--flow-id", default=None, help="Optional specific flow_id when reading from SQLite.")
parser.add_argument("--output-dir", required=True, help="Directory where <prompt_audio_key>.wav files will be written.")
parser.add_argument("--preview-dir", default=None, help="Optional directory to mirror generated WAV files into.")
parser.add_argument(
"--provider",
default=_default_provider_name(),
help="TTS provider name. Defaults to AI_VOICE_TTS_PROVIDER or yandex.",
)
parser.add_argument("--sample-rate", type=int, default=8000, help="Target WAV sample rate. Defaults to 8000.")
parser.add_argument("--default-language", default="ru", help="Fallback language for ambiguous prompts. Defaults to ru.")
parser.add_argument(
"--language-override",
action="append",
default=[],
help="Explicit language override in prompt_audio_key=language form. May be repeated.",
)
parser.add_argument("--dry-run", action="store_true", help="Print planned prompt files without generating audio.")
return parser.parse_args()
def _parse_language_overrides(raw_overrides: list[str]) -> dict[str, str]:
overrides: dict[str, str] = {}
for raw in raw_overrides:
item = str(raw or "").strip()
if not item:
continue
key, sep, value = item.partition("=")
if not sep or not key.strip() or not value.strip():
raise SystemExit(f"Invalid --language-override value: {raw!r}")
overrides[key.strip()] = value.strip()
return overrides
def _load_flow_document(args: argparse.Namespace) -> dict[str, Any]:
if args.flow_json_file:
return json.loads(Path(args.flow_json_file).read_text(encoding="utf-8"))
conn = sqlite3.connect(str(args.sqlite_db))
try:
if str(args.flow_id or "").strip():
row = conn.execute(
"select flow_json from ivr_flows where flow_id = ? order by id desc limit 1",
(args.flow_id.strip(),),
).fetchone()
else:
row = conn.execute(
"select flow_json from ivr_flows where is_active = 1 order by id desc limit 1"
).fetchone()
finally:
conn.close()
if row is None or not str(row[0] or "").strip():
raise SystemExit("Unable to load IVR flow JSON")
return json.loads(row[0])
def _node_identity(node: dict[str, Any]) -> str:
return str(node.get("node_id") or node.get("id") or "").strip()
def _infer_language(node: dict[str, Any], *, prompt_key: str, default_language: str) -> str:
candidates = [
prompt_key,
_node_identity(node),
str(node.get("resolved_queue_code") or "").strip(),
str(node.get("outcome_code") or "").strip(),
]
for candidate in candidates:
lowered = candidate.lower()
if any(token in lowered for token in ("_kz", "-kz", "_kk", "-kk", "kz_", "kk_")):
return "kz"
if any(token in lowered for token in ("_ru", "-ru", "ru_")):
return "ru"
text = str(node.get("prompt_text") or "").strip().lower()
has_kazakh_letters = any(char in text for char in "әіңғүұқөһ")
has_russian_wording = any(token in text for token in ("здравствуйте", "добро пожаловать", "русского", "службы поддержки", "отдела продаж"))
if has_kazakh_letters and not has_russian_wording:
return "kz"
return str(default_language or "ru").strip() or "ru"
def _collect_prompts(
flow_document: dict[str, Any],
*,
default_language: str,
language_overrides: dict[str, str],
) -> list[dict[str, str]]:
prompts: list[dict[str, str]] = []
seen: dict[str, str] = {}
def _append_prompt(*, node: dict[str, Any], prompt_key: str, prompt_text: str, language: str | None = None) -> None:
normalized_key = str(prompt_key or "").strip()
normalized_text = str(prompt_text or "").strip()
if not normalized_key or not normalized_text:
return
previous_text = seen.get(normalized_key)
if previous_text is not None and previous_text != normalized_text:
raise SystemExit(f"Prompt key {normalized_key!r} is reused with different texts")
seen[normalized_key] = normalized_text
prompts.append(
{
"prompt_audio_key": normalized_key,
"prompt_text": normalized_text,
"language": language_overrides.get(
normalized_key,
str(language or "").strip()
or _infer_language(node, prompt_key=normalized_key, default_language=default_language),
),
"node_id": _node_identity(node),
}
)
for raw_node in flow_document.get("nodes", []):
node = raw_node if isinstance(raw_node, dict) else None
if node is None:
continue
prompt_sequence = node.get("prompt_sequence")
if isinstance(prompt_sequence, list):
for prompt in prompt_sequence:
if not isinstance(prompt, dict):
continue
_append_prompt(
node=node,
prompt_key=str(prompt.get("prompt_audio_key") or ""),
prompt_text=str(prompt.get("prompt_text") or ""),
language=str(prompt.get("language") or "").strip() or None,
)
_append_prompt(
node=node,
prompt_key=str(node.get("prompt_audio_key") or ""),
prompt_text=str(node.get("prompt_text") or ""),
)
return prompts
def _write_atomic(target: Path, payload: bytes) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
temp_path: str | None = None
try:
with tempfile.NamedTemporaryFile(dir=target.parent, delete=False, suffix=".tmp") as handle:
handle.write(payload)
temp_path = handle.name
Path(temp_path).replace(target)
target.chmod(0o644)
finally:
if temp_path:
try:
Path(temp_path).unlink(missing_ok=True)
except OSError:
pass
def _materialize_prompt(
*,
provider_name: str,
prompt_text: str,
language: str,
sample_rate: int,
output_path: Path,
) -> None:
provider = build_tts_provider(provider_name)
synthesis = provider.synthesize(prompt_text, language=language)
pcm_bytes = resample_pcm16le(
synthesis.audio_bytes,
input_rate_hz=synthesis.sample_rate_hz,
output_rate_hz=max(sample_rate, 1),
)
wav_bytes = pcm16le_to_wav_bytes(
pcm_bytes,
sample_rate_hz=max(sample_rate, 1),
)
_write_atomic(output_path, wav_bytes)
def main() -> int:
args = _parse_args()
flow_document = _load_flow_document(args)
language_overrides = _parse_language_overrides(args.language_override)
prompts = _collect_prompts(
flow_document,
default_language=args.default_language,
language_overrides=language_overrides,
)
output_dir = Path(args.output_dir).expanduser()
preview_dir = Path(args.preview_dir).expanduser() if args.preview_dir else None
output_dir.mkdir(parents=True, exist_ok=True)
if preview_dir is not None:
preview_dir.mkdir(parents=True, exist_ok=True)
generated: list[dict[str, str]] = []
for prompt in prompts:
output_path = output_dir / f"{prompt['prompt_audio_key']}.wav"
if not args.dry_run:
_materialize_prompt(
provider_name=args.provider,
prompt_text=prompt["prompt_text"],
language=prompt["language"],
sample_rate=args.sample_rate,
output_path=output_path,
)
if preview_dir is not None:
preview_path = preview_dir / output_path.name
shutil.copyfile(output_path, preview_path)
preview_path.chmod(0o644)
generated.append(
{
**prompt,
"output_path": str(output_path),
}
)
print(json.dumps({"provider": args.provider, "count": len(generated), "prompts": generated}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())