Initial voice call center app

This commit is contained in:
Magzhan
2026-06-20 17:38:19 +05:00
commit bbb6985876
12 changed files with 2833 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
OPENAI_API_KEY=your_openai_api_key
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# OpenAI docs currently recommend smaller variants for latency/cost.
# Override this if your account has a different available model.
OPENAI_MODEL=gpt-5.4-nano
OPENAI_REASONING_EFFORT=none
# Voice TTS settings.
AI_VOICE_TTS_PROVIDER=elevenlabs
AI_VOICE_TTS_ELEVENLABS_MODEL_ID=eleven_v3
AI_VOICE_TTS_ELEVENLABS_STREAMING_FALLBACK_MODEL=eleven_flash_v2_5
AI_VOICE_TTS_ELEVENLABS_OUTPUT_FORMAT=mp3_44100_192
AI_VOICE_V2_STREAMING_TTS=0
AI_VOICE_TTS_ELEVENLABS_VOICE_ID=YjESejviApN7SHrbfnA2
# Legacy names are still supported by the app.
ELEVENLABS_VOICE_ID=YjESejviApN7SHrbfnA2
ELEVENLABS_STT_MODEL=scribe_v2_realtime
ELEVENLABS_TTS_MODEL=eleven_v3
ELEVENLABS_TTS_OUTPUT_FORMAT=mp3_44100_192
ELEVENLABS_TTS_SPEED=1.0
ELEVENLABS_TTS_STABILITY=0.4
ELEVENLABS_TTS_SIMILARITY_BOOST=0.8
# Leave empty for automatic language detection, or set ru/en/kk/etc.
ELEVENLABS_STT_LANGUAGE=
ELEVENLABS_TTS_LANGUAGE=ru
INITIAL_GREETING_TTS_LANGUAGE=
DEFAULT_CONVERSATION_LANGUAGE=ru
# Small spoken hesitations. Keep the rate low for a professional call-center voice.
HUMANIZER_ENABLED=1
HUMANIZER_FILLER_RATE=0.15
HUMANIZER_FILLERS=м-м,эм
# Local knowledge base from JSONL files in ./knowledge.
LOCAL_KB_ENABLED=1
LOCAL_KB_DIR=knowledge
LOCAL_KB_MAX_RESULTS=4
LOCAL_KB_MIN_SCORE=2.0
INITIAL_GREETING=Сәлеметсіз бе! Операторға дұрыс бағыттау үшін қызмет көрсету тілін таңдаңыз: қазақша немесе орысша. Здравствуйте! Чтобы правильно перевести вас на оператора, выберите язык обслуживания: казахский или русский
OPERATOR_STYLE_INSTRUCTIONS=Говори как живой оператор в коротком телефонном разговоре. Реплики обычно одна-две короткие фразы. Не отвечай справочной карточкой: без заголовков «Адрес», «Телефон», «Электронная почта», без списков и без перечисления всех контактов подряд. Не начинай каждую реплику со слов «спасибо», «поняла», «отлично», «зафиксировала» или имени клиента. Слово «отлично» не используй для данных клиента или проблемной ситуации. Не говори «адрес зафиксировала», «данные зафиксировала», «для CRM», «передам специалисту», «подключу специалиста», если клиент сам не просил. Если имя могло быть распознано неверно, не обращайся по имени; используй нейтральное обращение. Даже когда имя известно, используй его редко и не используй имя в ответе сразу после сбора первичных данных. После получения имени, города и адреса не пересказывай их полностью и не повторяй город рядом с адресом. Хороший ответ: «Хорошо. С чем помочь по адресу Туран сорок шесть Б?» или «Поняла, Туран сорок шесть Б. Что случилось?». Плохой ответ: «Отлично, Магжан. Адрес зафиксировала: город Астана, улица Туран, дом сорок шесть Б». Благодари только когда это звучит уместно. В обычном диалоге используй живые фразы: «сейчас посмотрю», «давайте проверим», «подскажу», «уточню один момент». Избегай канцелярита, роботичных подтверждений и фраз вроде «вам удобнее получить информацию».
ASSISTANT_INSTRUCTIONS=Ты Айнур, оператор контакт-центра QazaqGasAimaq для входящих звонков. Твоя задача - коротко собрать первичные данные клиента для обработки обращения. Обязательные первичные данные: имя клиента, город и адрес. В начале разговора обязательно попроси недостающие данные: если имени еще нет, спроси имя; если города или адреса еще нет, спроси «подскажите пожалуйста ваш город и адрес». Если клиент озвучил только часть обязательных данных, вежливо попроси только то, чего не хватает. Пока имя, город и адрес не получены, не помогай по сути обращения и не давай консультацию; коротко объясни, что сначала нужно уточнить данные для обработки обращения. После ответа клиента не спрашивай уже полученные данные повторно; для справочных вопросов считай адрес достаточным, если клиент назвал город и понятный адрес, улицу или дом. Не требуй квартиру или полный адрес, если это не нужно для конкретной заявки. Используй полученные данные как внутренний контекст, но не проговаривай клиенту слова CRM, аналитика или «зафиксировала». Отвечай естественно, кратко и по делу. Сначала старайся сама обработать вопрос и помочь клиенту по доступной информации; не предлагай подключить, перевести или передать клиента специалисту, если клиент сам этого явно не попросил. Когда говоришь о себе, используй женский род: могла, смогла, сделала, готова, проверила, нашла. Не завершай диалог самостоятельно и говори «до свидания» только если клиент явно попрощался или попросил завершить разговор. Все ответы должны быть готовы для озвучки: не используй цифры в клиентском ответе. Числа, сроки, суммы, адресные номера и телефоны пиши словами так, как их нужно произнести. Телефонные номера произноси словами по цифрам или удобными короткими группами, без символов плюс, скобок и дефисов. Не используй Markdown, URL или таблицы
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.py[cod]
.env
.DS_Store
.playwright-mcp/
+50
View File
@@ -0,0 +1,50 @@
# Simple Call Center
Простой Python/FastAPI прототип голосовой сессии:
1. браузер отправляет микрофон в WebSocket как PCM 16 kHz;
2. сервер стримит аудио в ElevenLabs realtime STT;
3. финальный фрагмент речи отправляется в OpenAI Responses API с общей историей текущей сессии;
4. текст ответа одновременно стримится в ElevenLabs TTS WebSocket;
5. браузер проигрывает PCM-аудио чанки по мере генерации.
## Быстрый старт
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
```
Заполните `.env`:
```bash
OPENAI_API_KEY=...
ELEVENLABS_API_KEY=...
```
Запуск:
```bash
uvicorn app.main:app --reload --port 8000
```
Откройте [http://127.0.0.1:8000](http://127.0.0.1:8000).
## Настройки
- `OPENAI_MODEL` по умолчанию стоит `gpt-5.4-nano`, потому что в актуальной документации OpenAI `gpt-5.4 nano` описан как самый дешевый GPT-5.4-class вариант, а `gpt-5.4 mini` и `nano` рекомендуются для latency/cost задач.
- `ELEVENLABS_STT_MODEL=scribe_v2_realtime` использует realtime STT WebSocket.
- `AI_VOICE_V2_STREAMING_TTS=0` использует обычный HTTP TTS, поэтому `AI_VOICE_TTS_ELEVENLABS_MODEL_ID=eleven_v3` применяется напрямую.
- Если вернуть `AI_VOICE_V2_STREAMING_TTS=1`, приложение автоматически использует `AI_VOICE_TTS_ELEVENLABS_STREAMING_FALLBACK_MODEL=eleven_flash_v2_5`, потому что ElevenLabs `/stream-input` не поддерживает `eleven_v3`.
- `LOCAL_KB_ENABLED=1` включает локальную базу знаний из JSONL-файлов в `knowledge/`.
- `LOCAL_KB_MAX_RESULTS=4` задает, сколько найденных записей подмешивать в один ответ.
- Контекст хранится в памяти на один WebSocket-сеанс браузера. Кнопка reset очищает локальную историю.
## Полезные ссылки
- OpenAI streaming Responses API: https://developers.openai.com/api/docs/guides/streaming-responses
- OpenAI models: https://developers.openai.com/api/docs/models
- ElevenLabs realtime STT: https://elevenlabs.io/docs/api-reference/speech-to-text/v-1-speech-to-text-realtime
- ElevenLabs realtime TTS WebSocket: https://elevenlabs.io/docs/eleven-api/guides/how-to/websockets/realtime-tts
+1
View File
@@ -0,0 +1 @@
"""Simple call-center voice app."""
+267
View File
@@ -0,0 +1,267 @@
from __future__ import annotations
import json
import math
import os
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from typing import Any
TOKEN_RE = re.compile(r"[0-9A-Za-zА-Яа-яЁёӘәҒғҚқҢңӨөҰұҮүҺһІі]+", re.UNICODE)
WHITESPACE_RE = re.compile(r"\s+")
STOPWORDS = {
"а",
"без",
"бы",
"в",
"во",
"где",
"для",
"до",
"его",
"ее",
"если",
"за",
"и",
"или",
"как",
"какая",
"какие",
"какой",
"когда",
"кто",
"ли",
"мне",
"можно",
"мы",
"на",
"надо",
"не",
"нет",
"но",
"о",
"об",
"по",
"подскажите",
"пожалуйста",
"при",
"с",
"со",
"то",
"у",
"что",
"чтобы",
"это",
"я",
}
@dataclass(frozen=True)
class KnowledgeHit:
score: float
record: dict[str, Any]
@dataclass
class KnowledgeDocument:
record: dict[str, Any]
title_tokens: set[str]
keyword_tokens: set[str]
content_tokens: Counter[str]
normalized_text: str
class KnowledgeBase:
def __init__(self, documents: list[KnowledgeDocument], source_files: list[str]):
self.documents = documents
self.source_files = source_files
self._idf = self._build_idf(documents)
@classmethod
def empty(cls) -> "KnowledgeBase":
return cls([], [])
@classmethod
def from_directory(cls, directory: str) -> "KnowledgeBase":
root = Path(directory)
if not root.exists():
return cls.empty()
records: list[dict[str, Any]] = []
source_files: list[str] = []
for path in sorted(root.glob("*.jsonl")):
source_files.append(str(path))
with path.open("r", encoding="utf-8") as file:
for line in file:
line = line.strip()
if not line:
continue
records.append(json.loads(line))
documents = [build_document(record) for record in records]
return cls(documents, source_files)
@property
def size(self) -> int:
return len(self.documents)
def search(self, query: str, limit: int = 4, min_score: float = 2.0) -> list[KnowledgeHit]:
query_tokens = tokenize(query)
if not query_tokens:
return []
query_text = normalize_text(query)
hits: list[KnowledgeHit] = []
for document in self.documents:
score = self._score_document(document, query_tokens, query_text)
if score >= min_score:
hits.append(KnowledgeHit(score=score, record=document.record))
hits.sort(key=lambda hit: hit.score, reverse=True)
return hits[:limit]
def _score_document(
self,
document: KnowledgeDocument,
query_tokens: list[str],
query_text: str,
) -> float:
unique_query_tokens = set(query_tokens)
score = 0.0
for token in unique_query_tokens:
idf = self._idf.get(token, 1.0)
if token in document.title_tokens:
score += 3.4 * idf
if token in document.keyword_tokens:
score += 2.6 * idf
count = document.content_tokens.get(token, 0)
if count:
score += min(count, 3) * idf
if query_text and query_text in document.normalized_text:
score += 8.0
elif len(unique_query_tokens) >= 3:
overlap = len(unique_query_tokens & set(document.content_tokens))
score += overlap / len(unique_query_tokens)
return score
@staticmethod
def _build_idf(documents: list[KnowledgeDocument]) -> dict[str, float]:
document_count = max(len(documents), 1)
document_frequency: Counter[str] = Counter()
for document in documents:
document_frequency.update(set(document.content_tokens))
return {
token: math.log((document_count + 1) / (frequency + 1)) + 1
for token, frequency in document_frequency.items()
}
def build_document(record: dict[str, Any]) -> KnowledgeDocument:
title_text = " ".join(
filter(
None,
[
record.get("title", ""),
record.get("question", ""),
record.get("category", ""),
record.get("region_code", ""),
],
)
)
keywords = record.get("keywords") or []
keyword_text = " ".join(str(keyword) for keyword in keywords)
content_text = " ".join(
filter(
None,
[
record.get("content", ""),
record.get("short_answer", ""),
record.get("full_answer", ""),
keyword_text,
],
)
)
return KnowledgeDocument(
record=record,
title_tokens=set(tokenize(title_text)),
keyword_tokens=set(tokenize(keyword_text)),
content_tokens=Counter(tokenize(content_text)),
normalized_text=normalize_text(f"{title_text} {content_text}"),
)
def format_knowledge_context(hits: list[KnowledgeHit], max_chars: int = 4200) -> str:
if not hits:
return ""
parts = [
"Служебный контекст базы знаний Aimaq. Используй только релевантные факты из этого блока. "
"Не называй клиенту ID записей и внутренние источники."
]
current_len = len(parts[0])
for index, hit in enumerate(hits, start=1):
record = hit.record
entry = format_record(index, hit.score, record)
if current_len + len(entry) > max_chars:
break
parts.append(entry)
current_len += len(entry)
return "\n\n".join(parts)
def format_record(index: int, score: float, record: dict[str, Any]) -> str:
title = record.get("title") or record.get("question") or "Без названия"
category = record.get("category") or "general"
region = record.get("region_code") or "global"
short_answer = record.get("short_answer") or ""
full_answer = record.get("full_answer") or ""
content = record.get("content") or ""
body = full_answer or short_answer or content
if short_answer and full_answer and short_answer not in full_answer:
body = f"{short_answer}\n{full_answer}"
body = trim_text(body, 1100)
return (
f"[KB {index}] score={score:.2f}\n"
f"ID: {record.get('external_id', '')}\n"
f"Категория: {category}\n"
f"Регион: {region}\n"
f"Заголовок: {title}\n"
f"Ответ/факты: {body}"
)
def tokenize(text: str) -> list[str]:
tokens = []
for token in TOKEN_RE.findall(text.casefold().replace("ё", "е")):
if len(token) < 2 or token in STOPWORDS:
continue
tokens.append(token)
return tokens
def normalize_text(text: str) -> str:
text = text.casefold().replace("ё", "е")
text = " ".join(TOKEN_RE.findall(text))
return WHITESPACE_RE.sub(" ", text).strip()
def trim_text(text: str, max_chars: int) -> str:
text = WHITESPACE_RE.sub(" ", text).strip()
if len(text) <= max_chars:
return text
return text[: max_chars - 1].rstrip() + ""
def resolve_knowledge_dir(project_dir: str, configured_dir: str) -> str:
if os.path.isabs(configured_dir):
return configured_dir
return os.path.join(project_dir, configured_dir)
+1366
View File
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
const TARGET_SAMPLE_RATE = 16000;
const startBtn = document.getElementById("startBtn");
const stopBtn = document.getElementById("stopBtn");
const resetBtn = document.getElementById("resetBtn");
const statusEl = document.getElementById("status");
const partialLine = document.getElementById("partialLine");
const messagesEl = document.getElementById("messages");
const canvas = document.getElementById("waveform");
const canvasCtx = canvas.getContext("2d");
const levelEl = document.getElementById("level");
const muteLabel = document.getElementById("muteLabel");
let socket;
let mediaStream;
let audioContext;
let sourceNode;
let processorNode;
let isRunning = false;
let isStopping = false;
let muteMic = false;
let currentAssistantLine = null;
let currentAssistantText = "";
let currentAssistantAudioChunks = 0;
let currentAssistantTtsFailed = false;
let currentAssistantTtsMessage = "";
let lastErrorMessage = "";
let playbackCursor = 0;
let pendingUnmuteTimer = null;
let encodedPlaybackChain = Promise.resolve();
let pendingEncodedAudioCount = 0;
const activeEncodedAudios = new Set();
drawIdleWave();
startBtn.addEventListener("click", startCall);
stopBtn.addEventListener("click", stopCall);
resetBtn.addEventListener("click", resetSession);
async function startCall() {
lastErrorMessage = "";
isStopping = false;
startBtn.disabled = true;
setStatus("connecting", "busy");
try {
socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
socket.binaryType = "arraybuffer";
socket.addEventListener("message", handleServerMessage);
socket.addEventListener("close", () => {
if (isRunning) stopCall(lastErrorMessage || "Звонок остановлен.");
if (!statusEl.classList.contains("error")) setStatus("offline", "");
});
socket.addEventListener("error", () => {
lastErrorMessage = "Ошибка WebSocket.";
setStatus("socket error", "error");
partialLine.textContent = lastErrorMessage;
addMessage("system", lastErrorMessage);
});
await waitForSocketOpen(socket);
await waitForServerReady(socket);
mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
},
});
audioContext = new AudioContext();
await audioContext.resume();
sourceNode = audioContext.createMediaStreamSource(mediaStream);
processorNode = audioContext.createScriptProcessor(4096, 1, 1);
processorNode.onaudioprocess = handleAudioProcess;
sourceNode.connect(processorNode);
processorNode.connect(audioContext.destination);
isRunning = true;
stopBtn.disabled = false;
resetBtn.disabled = false;
setStatus("live", "live");
partialLine.textContent = "Слушаю...";
socket.send(JSON.stringify({ type: "start_greeting" }));
} catch (error) {
lastErrorMessage = error.message || "Не удалось начать звонок.";
await cleanupAfterFailedStart();
startBtn.disabled = false;
stopBtn.disabled = true;
resetBtn.disabled = true;
setStatus("error", "error");
partialLine.textContent = lastErrorMessage;
addMessage("system", lastErrorMessage);
}
}
async function stopCall(message = "Звонок остановлен.") {
if (isStopping) return;
isStopping = true;
isRunning = false;
muteMic = false;
updateMuteLabel();
if (processorNode) {
processorNode.disconnect();
processorNode.onaudioprocess = null;
}
if (sourceNode) sourceNode.disconnect();
if (mediaStream) mediaStream.getTracks().forEach((track) => track.stop());
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
if (audioContext && audioContext.state !== "closed") await audioContext.close();
processorNode = null;
sourceNode = null;
mediaStream = null;
audioContext = null;
socket = null;
currentAssistantLine = null;
currentAssistantText = "";
currentAssistantAudioChunks = 0;
currentAssistantTtsFailed = false;
currentAssistantTtsMessage = "";
playbackCursor = 0;
pendingEncodedAudioCount = 0;
activeEncodedAudios.forEach((audio) => {
audio.pause();
audio.src = "";
});
activeEncodedAudios.clear();
encodedPlaybackChain = Promise.resolve();
startBtn.disabled = false;
stopBtn.disabled = true;
resetBtn.disabled = true;
partialLine.textContent = message;
levelEl.style.width = "0%";
drawIdleWave();
isStopping = false;
}
function resetSession() {
messagesEl.innerHTML = "";
currentAssistantLine = null;
currentAssistantText = "";
currentAssistantAudioChunks = 0;
currentAssistantTtsFailed = false;
currentAssistantTtsMessage = "";
partialLine.textContent = "Контекст очищен.";
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "reset" }));
}
}
function handleAudioProcess(event) {
const input = event.inputBuffer.getChannelData(0);
drawWave(input);
updateLevel(input);
if (!isRunning || muteMic || !socket || socket.readyState !== WebSocket.OPEN) return;
const downsampled = downsample(input, audioContext.sampleRate, TARGET_SAMPLE_RATE);
const pcm = floatTo16BitPcm(downsampled);
socket.send(pcm);
}
function handleServerMessage(event) {
const data = JSON.parse(event.data);
if (data.type === "ready") {
setStatus(data.model || "ready", "live");
} else if (data.type === "stt_ready") {
partialLine.textContent = "Слушаю...";
} else if (data.type === "stt_partial") {
partialLine.textContent = data.text || "Слушаю...";
} else if (data.type === "user_final") {
currentAssistantLine = null;
addMessage("user", data.text);
partialLine.textContent = "Думаю...";
} else if (data.type === "assistant_started") {
muteMic = true;
updateMuteLabel();
currentAssistantLine = addMessage("assistant", "");
currentAssistantText = "";
currentAssistantAudioChunks = 0;
currentAssistantTtsFailed = false;
currentAssistantTtsMessage = "";
setStatus("answering", "busy");
} else if (data.type === "assistant_delta") {
if (!currentAssistantLine) currentAssistantLine = addMessage("assistant", "");
currentAssistantLine.textContent += data.text;
currentAssistantText += data.text || "";
scrollTranscript();
} else if (data.type === "assistant_text_done") {
currentAssistantText = data.text || currentAssistantText;
partialLine.textContent = "Озвучиваю...";
} else if (data.type === "tts_audio") {
currentAssistantAudioChunks += 1;
if ((data.format || "").startsWith("mp3") || (data.mime_type || "").includes("mpeg")) {
playEncodedAudio(data.audio, data.mime_type || "audio/mpeg");
} else {
playPcmAudio(data.audio, data.sample_rate || TARGET_SAMPLE_RATE);
}
} else if (data.type === "tts_failed") {
currentAssistantTtsFailed = true;
currentAssistantTtsMessage = data.message || "ElevenLabs TTS не прислал аудио.";
partialLine.textContent = "ElevenLabs TTS недоступен, включаю резервную озвучку.";
addMessage("system", "ElevenLabs TTS недоступен, включаю резервную озвучку.");
} else if (data.type === "assistant_done") {
const fallbackStarted = maybeSpeakWithBrowserFallback();
if (!fallbackStarted) {
scheduleUnmuteAfterPlayback();
setStatus(currentAssistantTtsFailed ? "tts fallback" : "live", currentAssistantTtsFailed ? "busy" : "live");
partialLine.textContent = currentAssistantTtsFailed ? currentAssistantTtsMessage : "Слушаю...";
}
} else if (data.type === "reset_done") {
partialLine.textContent = "Контекст очищен.";
if (isRunning && socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "start_greeting" }));
}
} else if (data.type === "error") {
lastErrorMessage = data.message || "Ошибка.";
partialLine.textContent = lastErrorMessage;
addMessage("system", lastErrorMessage);
setStatus("error", "error");
}
}
function addMessage(role, text) {
const line = document.createElement("div");
line.className = `line ${role}`;
line.textContent = text;
messagesEl.appendChild(line);
scrollTranscript();
return line;
}
function scrollTranscript() {
const transcript = document.querySelector(".transcript");
transcript.scrollTop = transcript.scrollHeight;
}
function setStatus(text, className) {
statusEl.textContent = text;
statusEl.className = `status-pill ${className || ""}`.trim();
}
function maybeSpeakWithBrowserFallback() {
if (currentAssistantAudioChunks > 0) return false;
if (!currentAssistantText.trim()) return false;
if (!("speechSynthesis" in window) || typeof SpeechSynthesisUtterance === "undefined") {
partialLine.textContent = currentAssistantTtsFailed
? currentAssistantTtsMessage
: "TTS не прислал аудио.";
return false;
}
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(currentAssistantText);
utterance.lang = "ru-RU";
utterance.rate = 1.04;
utterance.pitch = 1;
utterance.onend = finishBrowserFallbackSpeech;
utterance.onerror = finishBrowserFallbackSpeech;
muteMic = true;
updateMuteLabel();
setStatus("browser voice", "busy");
partialLine.textContent = currentAssistantTtsFailed
? "Резервная озвучка включена: ElevenLabs TTS не дал аудио."
: "Резервная озвучка включена.";
window.speechSynthesis.speak(utterance);
return true;
}
function finishBrowserFallbackSpeech() {
muteMic = false;
updateMuteLabel();
setStatus("live", "live");
partialLine.textContent = "Слушаю...";
}
function updateMuteLabel() {
muteLabel.textContent = muteMic ? "muted" : "open";
}
function waitForSocketOpen(ws) {
return new Promise((resolve, reject) => {
ws.addEventListener("open", resolve, { once: true });
ws.addEventListener("error", reject, { once: true });
});
}
function waitForServerReady(ws) {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
cleanup();
reject(new Error("Сервер не ответил."));
}, 6000);
function cleanup() {
window.clearTimeout(timeout);
ws.removeEventListener("message", onMessage);
ws.removeEventListener("close", onClose);
ws.removeEventListener("error", onError);
}
function onMessage(event) {
const data = JSON.parse(event.data);
if (data.type === "ready") {
cleanup();
resolve(data);
} else if (data.type === "error") {
cleanup();
reject(new Error(data.message || "Сервер вернул ошибку."));
}
}
function onClose() {
cleanup();
reject(new Error("WebSocket закрыт."));
}
function onError() {
cleanup();
reject(new Error("Ошибка WebSocket."));
}
ws.addEventListener("message", onMessage);
ws.addEventListener("close", onClose);
ws.addEventListener("error", onError);
});
}
async function cleanupAfterFailedStart() {
if (processorNode) {
processorNode.disconnect();
processorNode.onaudioprocess = null;
}
if (sourceNode) sourceNode.disconnect();
if (mediaStream) mediaStream.getTracks().forEach((track) => track.stop());
if (socket && socket.readyState === WebSocket.OPEN) socket.close();
if (audioContext) await audioContext.close();
processorNode = null;
sourceNode = null;
mediaStream = null;
audioContext = null;
socket = null;
}
function downsample(buffer, inputRate, outputRate) {
if (outputRate === inputRate) return buffer;
const ratio = inputRate / outputRate;
const newLength = Math.round(buffer.length / ratio);
const result = new Float32Array(newLength);
let offsetResult = 0;
let offsetBuffer = 0;
while (offsetResult < result.length) {
const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
let accumulator = 0;
let count = 0;
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i += 1) {
accumulator += buffer[i];
count += 1;
}
result[offsetResult] = accumulator / Math.max(count, 1);
offsetResult += 1;
offsetBuffer = nextOffsetBuffer;
}
return result;
}
function floatTo16BitPcm(floatBuffer) {
const output = new Int16Array(floatBuffer.length);
for (let i = 0; i < floatBuffer.length; i += 1) {
const sample = Math.max(-1, Math.min(1, floatBuffer[i]));
output[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
}
return output.buffer;
}
function playPcmAudio(base64Audio, sampleRate) {
if (!audioContext) return;
if (audioContext.state === "suspended") audioContext.resume();
const bytes = Uint8Array.from(atob(base64Audio), (char) => char.charCodeAt(0));
const samples = new Int16Array(bytes.buffer);
const audioBuffer = audioContext.createBuffer(1, samples.length, sampleRate);
const channel = audioBuffer.getChannelData(0);
for (let i = 0; i < samples.length; i += 1) {
channel[i] = samples[i] / 32768;
}
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
const startAt = Math.max(audioContext.currentTime + 0.03, playbackCursor);
source.start(startAt);
playbackCursor = startAt + audioBuffer.duration;
}
function playEncodedAudio(base64Audio, mimeType) {
pendingEncodedAudioCount += 1;
encodedPlaybackChain = encodedPlaybackChain.then(
() =>
new Promise((resolve) => {
const bytes = Uint8Array.from(atob(base64Audio), (char) => char.charCodeAt(0));
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
activeEncodedAudios.add(audio);
function cleanup() {
URL.revokeObjectURL(url);
activeEncodedAudios.delete(audio);
pendingEncodedAudioCount = Math.max(0, pendingEncodedAudioCount - 1);
if (pendingEncodedAudioCount === 0 && muteMic) {
scheduleUnmuteAfterPlayback();
}
resolve();
}
audio.onended = cleanup;
audio.onerror = cleanup;
audio.play().catch(cleanup);
}),
);
}
function scheduleUnmuteAfterPlayback() {
if (pendingEncodedAudioCount > 0) return;
if (pendingUnmuteTimer) window.clearTimeout(pendingUnmuteTimer);
const remainingMs = audioContext ? Math.max(0, (playbackCursor - audioContext.currentTime) * 1000) : 0;
pendingUnmuteTimer = window.setTimeout(() => {
muteMic = false;
updateMuteLabel();
}, remainingMs + 120);
}
function updateLevel(buffer) {
let sum = 0;
for (let i = 0; i < buffer.length; i += 1) sum += buffer[i] * buffer[i];
const rms = Math.sqrt(sum / buffer.length);
const percent = Math.min(100, Math.round(rms * 260));
levelEl.style.width = `${percent}%`;
}
function drawIdleWave() {
canvasCtx.fillStyle = "#101820";
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.strokeStyle = "#2f9e80";
canvasCtx.lineWidth = 3;
canvasCtx.beginPath();
const mid = canvas.height / 2;
for (let x = 0; x < canvas.width; x += 1) {
const y = mid + Math.sin(x / 28) * 10 + Math.sin(x / 83) * 18;
if (x === 0) canvasCtx.moveTo(x, y);
else canvasCtx.lineTo(x, y);
}
canvasCtx.stroke();
}
function drawWave(buffer) {
canvasCtx.fillStyle = "#101820";
canvasCtx.fillRect(0, 0, canvas.width, canvas.height);
canvasCtx.strokeStyle = muteMic ? "#d9563f" : "#36c28f";
canvasCtx.lineWidth = 3;
canvasCtx.beginPath();
const slice = Math.max(1, Math.floor(buffer.length / canvas.width));
const mid = canvas.height / 2;
for (let x = 0; x < canvas.width; x += 1) {
const sample = buffer[x * slice] || 0;
const y = mid + sample * mid * 0.84;
if (x === 0) canvasCtx.moveTo(x, y);
else canvasCtx.lineTo(x, y);
}
canvasCtx.stroke();
}
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Simple Call Center</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<main class="shell">
<section class="workspace" aria-label="Voice session">
<header class="topbar">
<div>
<p class="eyebrow">Live voice session</p>
<h1>Simple Call Center</h1>
</div>
<div class="status-pill" id="status">offline</div>
</header>
<section class="stage" aria-label="Audio monitor">
<canvas id="waveform" width="1200" height="240"></canvas>
<div class="meter-row">
<div class="meter-label">Mic</div>
<div class="level-track"><div id="level" class="level-fill"></div></div>
<div class="meter-label" id="muteLabel">open</div>
</div>
</section>
<section class="controls" aria-label="Call controls">
<button id="startBtn" class="primary" type="button">Start</button>
<button id="stopBtn" type="button" disabled>Stop</button>
<button id="resetBtn" type="button" disabled>Reset</button>
</section>
<section class="transcript" aria-live="polite" aria-label="Transcript">
<div class="line system" id="partialLine">Готов к звонку.</div>
<div id="messages" class="messages"></div>
</section>
</section>
</main>
<script src="/static/app.js?v=mp3-192-1"></script>
</body>
</html>
+259
View File
@@ -0,0 +1,259 @@
:root {
color-scheme: light;
--bg: #eef2f6;
--ink: #101820;
--muted: #5f6f7a;
--line: #cfdae5;
--panel: #ffffff;
--panel-2: #f6f8fb;
--green: #0f8f61;
--green-dark: #08734d;
--blue: #1f6feb;
--coral: #d9563f;
--amber: #b7791f;
--shadow: 0 18px 48px rgba(16, 24, 32, 0.12);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
}
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
background:
linear-gradient(180deg, rgba(238, 242, 246, 0.94), rgba(226, 234, 241, 0.98)),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'%3E%3Cg fill='none' stroke='%23c4d1dc' stroke-opacity='.45'%3E%3Cpath d='M0 40h160M0 80h160M0 120h160M40 0v160M80 0v160M120 0v160'/%3E%3C/g%3E%3C/svg%3E");
color: var(--ink);
}
button {
min-width: 112px;
min-height: 44px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--ink);
font: inherit;
font-weight: 700;
cursor: pointer;
transition:
transform 140ms ease,
border-color 140ms ease,
background 140ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
border-color: #9fb1c0;
}
button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
button.primary {
border-color: var(--green);
background: var(--green);
color: #fff;
}
.shell {
width: min(1120px, calc(100vw - 32px));
margin: 0 auto;
padding: 32px 0;
}
.workspace {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px;
border-bottom: 1px solid var(--line);
background: var(--panel-2);
}
.eyebrow {
margin: 0 0 4px;
color: var(--muted);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0;
}
h1 {
margin: 0;
font-size: clamp(26px, 3vw, 40px);
line-height: 1.05;
letter-spacing: 0;
}
.status-pill {
min-width: 116px;
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 12px;
background: #fff;
color: var(--muted);
font-size: 13px;
font-weight: 800;
text-align: center;
}
.status-pill.live {
border-color: rgba(15, 143, 97, 0.32);
background: rgba(15, 143, 97, 0.11);
color: var(--green-dark);
}
.status-pill.busy {
border-color: rgba(31, 111, 235, 0.3);
background: rgba(31, 111, 235, 0.1);
color: var(--blue);
}
.status-pill.error {
border-color: rgba(217, 86, 63, 0.35);
background: rgba(217, 86, 63, 0.12);
color: var(--coral);
}
.stage {
padding: 22px 24px 18px;
border-bottom: 1px solid var(--line);
}
#waveform {
display: block;
width: 100%;
height: clamp(160px, 25vw, 240px);
border: 1px solid #c8d6df;
border-radius: 8px;
background: #101820;
}
.meter-row {
display: grid;
grid-template-columns: 42px minmax(120px, 1fr) 72px;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.meter-label {
color: var(--muted);
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
}
.level-track {
height: 10px;
overflow: hidden;
border-radius: 999px;
background: #dbe5ed;
}
.level-fill {
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, var(--green), var(--amber), var(--coral));
transition: width 80ms linear;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 18px 24px;
border-bottom: 1px solid var(--line);
}
.transcript {
min-height: 260px;
max-height: 44vh;
overflow-y: auto;
padding: 20px 24px 24px;
background: #fbfcfd;
}
.messages {
display: grid;
gap: 12px;
}
.line {
max-width: 82%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px 14px;
background: #fff;
line-height: 1.45;
white-space: pre-wrap;
}
.line.system {
max-width: none;
margin-bottom: 14px;
border-color: #d8dde3;
background: #eef4f8;
color: var(--muted);
}
.line.user {
justify-self: start;
border-color: rgba(15, 143, 97, 0.26);
background: rgba(15, 143, 97, 0.08);
}
.line.assistant {
justify-self: end;
border-color: rgba(31, 111, 235, 0.24);
background: rgba(31, 111, 235, 0.08);
}
@media (max-width: 680px) {
.shell {
width: 100%;
padding: 0;
}
.workspace {
min-height: 100vh;
border-width: 0;
border-radius: 0;
}
.topbar {
align-items: flex-start;
flex-direction: column;
}
.status-pill {
width: 100%;
}
.controls button {
flex: 1 1 0;
min-width: 0;
}
.line {
max-width: 94%;
}
}
+17
View File
@@ -0,0 +1,17 @@
{"external_id": "kga_branch_01_almaty_region", "title": "Контакты филиала — Алматинская область", "language": "ru", "region_code": "almaty_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Алматинская область\nПроизводственный филиал: Жетысуский производственный филиал\nАдрес и контакты: область Жетісу, 040000 г. Талдыкорган, ул. Кабанбай батыра, д. 22А (по карте отображается как 22В)\nТел.: +7 (7282) 60-10-46\nЭл. почта: zhtpf@aimaq.kz\nОбслуживаемый регион из исходной таблицы: Алматинская и Жетысуская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "090a8ce8291258efb894f57e454b7a5bebb031276dfbc5e0bec910d22bac7915", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 1, "branch_name": "Жетысуский производственный филиал", "original_region_text": "Алматинская и Жетысуская обл.", "all_region_codes": ["almaty_region", "jetisu_region"]}}
{"external_id": "kga_branch_01_jetisu_region", "title": "Контакты филиала — область Жетісу", "language": "ru", "region_code": "jetisu_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: область Жетісу\nПроизводственный филиал: Жетысуский производственный филиал\nАдрес и контакты: область Жетісу, 040000 г. Талдыкорган, ул. Кабанбай батыра, д. 22А (по карте отображается как 22В)\nТел.: +7 (7282) 60-10-46\nЭл. почта: zhtpf@aimaq.kz\nОбслуживаемый регион из исходной таблицы: Алматинская и Жетысуская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "cb3fb663471c18454e94d0db64de0467abe53e1a658f44cd8af2cbd8dcec5fd6", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 1, "branch_name": "Жетысуский производственный филиал", "original_region_text": "Алматинская и Жетысуская обл.", "all_region_codes": ["almaty_region", "jetisu_region"]}}
{"external_id": "kga_branch_02_almaty_city", "title": "Контакты филиала — Алматы", "language": "ru", "region_code": "almaty_city", "category": "Производственные филиалы", "status": "published", "content": "Регион: Алматы\nПроизводственный филиал: Алматинский производственный филиал\nАдрес и контакты: г.Алматы, пр. Рыскулова, 99\nТел.: 8 (727) 331-70-10 (прием.)\nЭл. почта: K.Abylkasimova@ktga.kz\nОбслуживаемый регион из исходной таблицы: г.Алматы", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "b837afd2d9c92ba9644572aa8e5f8ef34bc6c7fa0becf5d585321466f5dcde01", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 2, "branch_name": "Алматинский производственный филиал", "original_region_text": "г.Алматы", "all_region_codes": ["almaty_city"]}}
{"external_id": "kga_branch_03_aktobe_region", "title": "Контакты филиала — Актюбинская область", "language": "ru", "region_code": "aktobe_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Актюбинская область\nПроизводственный филиал: Актюбинский производственный филиал\nАдрес и контакты: г. Актобе, проспект 312 Стрелковой дивизии, 22\nТел.: 8 (7132) 74-15-88\nЭл. почта: ktga_aktobe@mail.ru\nОбслуживаемый регион из исходной таблицы: Актюбинская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "aaa1f61e6552fe101bbe55506d091d8a55bf1246f27b1e06d86521e06f02be10", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 3, "branch_name": "Актюбинский производственный филиал", "original_region_text": "Актюбинская обл.", "all_region_codes": ["aktobe_region"]}}
{"external_id": "kga_branch_04_atyrau_region", "title": "Контакты филиала — Атырауская область", "language": "ru", "region_code": "atyrau_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Атырауская область\nПроизводственный филиал: Атырауский производственный филиал\nАдрес и контакты: г. Атырау, ул. З.Гумарова, 94\nТелефон: 8 (712) 255-86-30\nЭл. почта: atpf@ktga.kz\nОбслуживаемый регион из исходной таблицы: Атырауская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "cad8754faf03ff84b971efbc58f7da546c8846ffaea658818e11f79f512cfa62", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 4, "branch_name": "Атырауский производственный филиал", "original_region_text": "Атырауская обл.", "all_region_codes": ["atyrau_region"]}}
{"external_id": "kga_branch_05_east_kazakhstan_region", "title": "Контакты филиала — Восточно-Казахстанская область", "language": "ru", "region_code": "east_kazakhstan_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Восточно-Казахстанская область\nПроизводственный филиал: Восточно-Казахстанский производственный филиал\nАдрес и контакты: г. Зайсан, ул. Кондюрина, 26\nТел.: 8(72340) 311-82; 790-30\nЭл. почта: VKPF-KTGA@mail.ru\nОбслуживаемый регион из исходной таблицы: Восточно-Казахстанская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "40577bf8be53f838bf070dee485a32adc74193c995deb330d490ca57cb945e5a", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 5, "branch_name": "Восточно-Казахстанский производственный филиал", "original_region_text": "Восточно-Казахстанская обл.", "all_region_codes": ["east_kazakhstan_region"]}}
{"external_id": "kga_branch_06_zhambyl_region", "title": "Контакты филиала — Жамбылская область", "language": "ru", "region_code": "zhambyl_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Жамбылская область\nПроизводственный филиал: Жамбылский производственный филиал\nАдрес и контакты: г. Тараз, ул. Койгельды, 177\nТел.: 8 (7262) 99-99-04, 59-92-00\nЭл. почта: secretary1@taraz.ktga.kz\nЭл. почта: zhpf2020@mail.ru\nОбслуживаемый регион из исходной таблицы: Жамбылская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "796f67a5cc16fc6c4daaf981547fe49495f1841a4d6f63f854820d80555d9b43", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 6, "branch_name": "Жамбылский производственный филиал", "original_region_text": "Жамбылская обл.", "all_region_codes": ["zhambyl_region"]}}
{"external_id": "kga_branch_07_west_kazakhstan_region", "title": "Контакты филиала — Западно-Казахстанская область", "language": "ru", "region_code": "west_kazakhstan_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Западно-Казахстанская область\nПроизводственный филиал: Западно-Казахстанский производственный филиал\nАдрес и контакты: г. Уральск, ул. Гагарина, 29 Тел.: 8 (7112) 93-32-70, 93-32-71 (по вопросам начисления и техобслуживания) 87057962891 (по начислению и оплаты и техобслуживания)\nЭл. почта: zkpf@ktga.kz\nОбслуживаемый регион из исходной таблицы: Западно-Казахстанская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "abe941472be376da839bedf43e538e07d6872e5dad83c35a242ef1a9e5ca26f2", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 7, "branch_name": "Западно-Казахстанский производственный филиал", "original_region_text": "Западно-Казахстанская обл.", "all_region_codes": ["west_kazakhstan_region"]}}
{"external_id": "kga_branch_08_karaganda_region", "title": "Контакты филиала — Карагандинская область", "language": "ru", "region_code": "karaganda_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Карагандинская область\nПроизводственный филиал: Карагандинский производственный филиал\nАдрес и контакты: г.Караганда, ул.Ермекова 29\nТел: 8 7212 25 46 49\nЭл. почта: karpf@ktga.kz\nОбслуживаемый регион из исходной таблицы: Карагандинская область и область Ұлытау", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "66ed1b33f151dcf6201ae129479978fd3a5da459ed87f676ec961e331ca72908", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 8, "branch_name": "Карагандинский производственный филиал", "original_region_text": "Карагандинская область и область Ұлытау", "all_region_codes": ["karaganda_region", "ulytau_region"]}}
{"external_id": "kga_branch_08_ulytau_region", "title": "Контакты филиала — область Ұлытау", "language": "ru", "region_code": "ulytau_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: область Ұлытау\nПроизводственный филиал: Карагандинский производственный филиал\nАдрес и контакты: г.Караганда, ул.Ермекова 29\nТел: 8 7212 25 46 49\nЭл. почта: karpf@ktga.kz\nОбслуживаемый регион из исходной таблицы: Карагандинская область и область Ұлытау", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "d05d4bf0273c231e83e8abd6f0bcdea46194d670d30c3e52eab3ffa1921be01c", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 8, "branch_name": "Карагандинский производственный филиал", "original_region_text": "Карагандинская область и область Ұлытау", "all_region_codes": ["karaganda_region", "ulytau_region"]}}
{"external_id": "kga_branch_09_kostanay_region", "title": "Контакты филиала — Костанайская область", "language": "ru", "region_code": "kostanay_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Костанайская область\nПроизводственный филиал: Костанайский производственный филиал\nАдрес и контакты: г. Костанай, ул.Алтынсарина, 130\nТел.: 8(714) 254-06-07\nТ/факс: 8(714) 253-30-11\nЭл. почта: aymak.kostanay@ktga.kz\nОбслуживаемый регион из исходной таблицы: Костанайская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "82637d5f7d3e79efd9d13f369858baab5ee5d6a6dc828e5787451ece4ba00524", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 9, "branch_name": "Костанайский производственный филиал", "original_region_text": "Костанайская обл.", "all_region_codes": ["kostanay_region"]}}
{"external_id": "kga_branch_10_kyzylorda_region", "title": "Контакты филиала — Кызылординская область", "language": "ru", "region_code": "kyzylorda_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Кызылординская область\nПроизводственный филиал: Кызылординский производственный филиал\nАдрес и контакты: г. Кызылорда, ул. Бейбарс Султана, 1\nТел.: +7 (7242) 55-17-42\nОперационная служба по обслуживанию населения +7 (7242) 55-17-45\nЭл. почта: kyzylorda@ktga.kz\nОбслуживаемый регион из исходной таблицы: Кызылординская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "c8142156b42dcce027a069f90ef041e4d31caedac6a5e4e269456b9068fe8e6c", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 10, "branch_name": "Кызылординский производственный филиал", "original_region_text": "Кызылординская обл.", "all_region_codes": ["kyzylorda_region"]}}
{"external_id": "kga_branch_11_mangystau_region", "title": "Контакты филиала — Мангистауская область", "language": "ru", "region_code": "mangystau_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Мангистауская область\nПроизводственный филиал: Мангистауский производственный филиал\nАдрес и контакты: Мангистауская обл, г. Актау, 12 мкр, БЦ «Капитал», здание 79/4\nТел.: +7 (7292) 701-170, 701-179\nЭл. почта: mpf_ktga@ktga.kz\nОбслуживаемый регион из исходной таблицы: Мангистауская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "d12830c7b968958f19c64fce0c6bde775aa268c70b92a4327b06a5c511cea644", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 11, "branch_name": "Мангистауский производственный филиал", "original_region_text": "Мангистауская обл.", "all_region_codes": ["mangystau_region"]}}
{"external_id": "kga_branch_12_astana_city", "title": "Контакты филиала — Астана", "language": "ru", "region_code": "astana_city", "category": "Производственные филиалы", "status": "published", "content": "Регион: Астана\nПроизводственный филиал: Астанинский производственный филиал\nАдрес и контакты: г.Астана, ул. Б.Момышулы 2/2\nЭл. почта: astana@aimaq.kz\nТел.: 8 (7172) 55-89-75\nОбслуживаемый регион из исходной таблицы: г. Астана и Акмолинская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "a626e732caf2c7b6acaa3d5f0c836cea157feec80f1c11a271a8516e247d99fa", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 12, "branch_name": "Астанинский производственный филиал", "original_region_text": "г. Астана и Акмолинская обл.", "all_region_codes": ["astana_city", "akmola_region"]}}
{"external_id": "kga_branch_12_akmola_region", "title": "Контакты филиала — Акмолинская область", "language": "ru", "region_code": "akmola_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Акмолинская область\nПроизводственный филиал: Астанинский производственный филиал\nАдрес и контакты: г.Астана, ул. Б.Момышулы 2/2\nЭл. почта: astana@aimaq.kz\nТел.: 8 (7172) 55-89-75\nОбслуживаемый регион из исходной таблицы: г. Астана и Акмолинская обл.", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "c4535e518212c0b1619958771be2808f45c7d61975ec89f5c31976b08dd9a4aa", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 12, "branch_name": "Астанинский производственный филиал", "original_region_text": "г. Астана и Акмолинская обл.", "all_region_codes": ["astana_city", "akmola_region"]}}
{"external_id": "kga_branch_13_shymkent_city", "title": "Контакты филиала — Шымкент", "language": "ru", "region_code": "shymkent_city", "category": "Производственные филиалы", "status": "published", "content": "Регион: Шымкент\nПроизводственный филиал: Шымкентский производственный филиал\nАдрес и контакты: г. Шымкент, Абайский район, Трасса Темирлан, здание 20/2\nТел.: 8 (7252) 39-74-81, 8771-949-92-60\nЭл. почта: kanc_shpf@ktga.kz\nОбслуживаемый регион из исходной таблицы: г.Шымкент", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "cdce3253e27d2a712d67f21c7c802ffcb2aeb87e33a0c98f7d08fd16b20c691a", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 13, "branch_name": "Шымкентский производственный филиал", "original_region_text": "г.Шымкент", "all_region_codes": ["shymkent_city"]}}
{"external_id": "kga_branch_14_turkistan_region", "title": "Контакты филиала — Туркестанская область", "language": "ru", "region_code": "turkistan_region", "category": "Производственные филиалы", "status": "published", "content": "Регион: Туркестанская область\nПроизводственный филиал: Туркестанский производственный филиал\nАдрес и контакты: г.Туркестан, Юр. адрес: Б.Майлина, строение 181. Факт. адрес: ул. Сапарбай болыс, уч. 1 А\nТел: 8(72533) 5-99-95, 5-99-91\nЭл. почта: tpf@ktga.kz\nОбслуживаемый регион из исходной таблицы: Туркестанская область", "source_type": "docx_table", "source_uri": "БЗ по проекту КГА (1).docx", "source_hash": "1e4ed8fcb15d62036511549fa430357979a511aa4c45477fa1308997a502048e", "metadata": {"source_doc": "БЗ по проекту КГА (1).docx", "source_table": "branches", "source_row_index": 14, "branch_name": "Туркестанский производственный филиал", "original_region_text": "Туркестанская область", "all_region_codes": ["turkistan_region"]}}
File diff suppressed because one or more lines are too long
+6
View File
@@ -0,0 +1,6 @@
fastapi>=0.115.0
uvicorn[standard]>=0.30.0
openai>=1.88.0
python-dotenv>=1.0.1
httpx>=0.28.0
websockets>=12.0,<16.0