Files
portal/docs/superpowers/experiments/2026-07-17-operator-mentor/engine_v9.py
T
Дмитрий 7d0c06ca51 docs(golos): v9 «друг-профи» + голосовой движок engine_v9 — идеал по слуху владельца
Системный разворот характера (по разбору владельца, что v8 впаривала/суше):
- Оператор = грамотный ДРУГ-КОЛЛЕГА в мире клиента (не продавец): понимает его боль (дорогой директ, клики золотые, клиентов то густо-то пусто), делится как о находке, на «вы», не эхает жаргон («директолог»→«та реклама, что крутится»). Знаний внутри нет — их несёт наставник, скорость сохранена.
- Наставник даёт ПОНИМАНИЕ {za_klientom,opora,fakt,flazhok}, не команду-приём.
- engine_v9.py: эталонный звук 1-в-1 + мозг v9 + реальный замер пауз + филлеры без «лезу в базу».

Шлифовка на слух (7 итераций): краткость→«вы»→друг-персона→филлеры человеческие→общие без хвостовых многоточий→ход-1 без «представилась»→код-стопор trim_reply (2-4 предложения до вывода, не рубит мысль)→«только по-русски» (no-show коверкался голосом)→код-подстраховка «вы».

Итог: реплики договорены, эхо 0, впаривания нет, паузы ~1.8с. Владелец: «нет ни одного замечания, создали идеал». Запись/ключи — ВНЕ git.

FLOOR-ESCAPE: владелец разрешил коммит («так фиксируй, это фиксируй тоже»).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 20:56:42 +03:00

631 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""╔══════════════════════════════════════════════════════════════════════════════╗
║ ДВИЖОК «АЛЕКСАНДРА» — голосовой робот-продажник Лидерры (сборка под ключ) ║
╚══════════════════════════════════════════════════════════════════════════════╝
Один файл = весь мозг + характер + живость + отрасль. Собрано из демо-кусков сессии 16.07.2026.
ЧТО ВНУТРИ (готово, работает):
• УШИ — Cartesia Ink (ink-whisper, ru) ear()
• ГОЛОС— Cartesia sonic-3 (Olga) synth()/csynth()
• МОЗГ — адаптивный: шаблон / flash / Haiku classify()+brain()
• ФИЛЛЕРЫ по смыслу + ПРЕДОХРАНИТЕЛЬ настроения choose_filler()/mood()
• ПОДДАКИВАНИЯ «угу/ага» пока говорит клиент client_with_backchannel()
• ФОН-КАБИНЕТ (-35, живой) office_bed()
• ПРОДАЮЩАЯ БАЗА (из утверждённого файла) load_baza()
• ОТРАСЛЕВАЯ ПРИЗМА + ЖИВОЙ Perplexity(Sonar) research_niche() ← сам ходит в веб
• ЖЁСТКИЙ РАЗВОРОТ ниши (тормоз-разведка + свап) detect_niche()+STALL_NOTE+swap
🔌 РОЗЕТКИ ПОД БОЙ (пометки 🔌PROD — вставить при интеграции, НЕ готово):
🔌PROD-ЦЕНЫ substitute_prices() → портальный LivePrices (сейчас заглушка).
🔌PROD-СТОРОЖ после brain() → портальный AnswerGuard (сейчас держат правила промта).
🔌PROD-ТЕЛЕФОН ear/synth → реальный SIP/стриминг (сейчас офлайн, клиент = синтез).
🔌PROD-ПЕРЕБИВ → barge-in (клиент заговорил — бот молчит). Не готово.
🔌PROD-НИША detect_niche() → извлечение ЛЮБОЙ ниши моделью (сейчас демо-ключи 2 ниш).
ЖИВОЙ Perplexity: Sonar через AITunnel (рублёвый ключ, ~5с), кеш otrasl_cache/. Новый аккаунт НЕ нужен.
"""
import os, time, json, base64, re, ssl, asyncio, hashlib, urllib.request, wave
try:
import truststore; truststore.inject_into_ssl()
except Exception:
pass
import ssl as _ssl
import requests
import websockets
import numpy as np
HERE = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(HERE, "fillers_cache"); os.makedirs(CACHE, exist_ok=True)
CART = open(os.path.join(HERE, "cartesia.key"), encoding="utf-8").read().strip()
AIT = open(os.path.join(HERE, "aitunnel.key"), encoding="utf-8").read().strip()
CTX = ssl.create_default_context(); CTX.load_default_certs()
try:
CTX.load_verify_locations(cafile=os.path.join(HERE, "russian_trusted_root.pem"))
except Exception:
pass
SR = 16000
V_ALEX = "9ed9f7e7-3ef6-4773-9dd3-ffcb479ca1f0"
V_CLIENT = "1e4176b1-3db9-44d6-a601-4fe68b041942"
FAST = "gemini-2.5-flash-lite"
SMART = "claude-haiku-4.5"
# ══════════════ МОЗГ v8: наставник-классификатор + оператор (лёгкий промпт) + база знаний ══════════════
# Весь звук ниже — как в эталоне (голос/фон/поддакивания/филлеры/темп). Меняется ТОЛЬКО мозг.
import sys as _sys
_sys.path.insert(0, HERE)
os.environ.setdefault("ALEX_AITUNNEL_KEY", os.path.join(HERE, "aitunnel.key"))
os.environ.setdefault("ALEX_EMBED", "aitunnel")
import stand9 as V9 # v9: наставник(mentor_understand — ПОНИМАНИЕ) + оператор(OP9_SYS — друг-профи) + BRIEF из базы знаний
def operator_stream(history, model, hint):
"""Оператор v9: тёплый характер OP9 + понимание наставника, СТРИМ (мерим время до первой фразы)."""
sys_op = V9.OP9_SYS.format(hint=hint, facts_lock=V9.FACTS_LOCK)
body = {"model": model, "max_tokens": 300, "temperature": 0.6, "stream": True, # запас, чтобы мысль договорилась (обрыв 1:40)
"messages": [{"role": "system", "content": sys_op}] + history}
req = urllib.request.Request("https://api.aitunnel.ru/v1/chat/completions",
data=json.dumps(body).encode(), method="POST")
req.add_header("Authorization", f"Bearer {AIT}"); req.add_header("Content-Type", "application/json")
t0 = time.time(); resp = urllib.request.urlopen(req, timeout=40, context=CTX)
full, tf = "", None
for raw in resp:
line = raw.decode("utf-8", "replace").strip()
if line.startswith("data:") and line[5:].strip() != "[DONE]":
try:
full += json.loads(line[5:].strip())["choices"][0].get("delta", {}).get("content", "") or ""
except Exception:
continue
if tf is None and re.search(r"[.!?…]", full):
tf = time.time() - t0
txt = re.sub(r"[*_`#>]+", "", full).strip()
return V9.trim_reply(txt), (tf or time.time() - t0) # код-стопор: один абзац до вопроса, мысль договорена
# ── ПРОДАЮЩАЯ БАЗА: грузим из утверждённого файла (единый источник) ──
BAZA_FILE = os.path.abspath(os.path.join(
HERE, "..", "..", "..", "..", "..", "..", "..", # запас; ниже перекрываем абсолютным
))
BAZA_ABS = r"c:\моя\проекты\портал crm\Документация\docs\superpowers\findings\2026-07-16-aleksandra-prodayushaya-baza.md"
def load_baza():
txt = open(BAZA_ABS, encoding="utf-8").read()
# берём от «ГЛАВНЫЙ КРЮЧОК» до «Легенда живых цен» (легенда — служебная, модели не нужна)
start = txt.find("## 🎣")
end = txt.find("## 🏷️")
body = txt[start:end if end > 0 else None].strip()
return substitute_prices(body)
# ── ФИКС ПРОИЗНОШЕНИЯ БРЕНДА (подтверждено на ухо: IPA-блок лечит «Лидерра»→«лидера») ──
# Cartesia sonic-3: U+0301 и SSML <phoneme> НЕ работают; работает IPA-блок <<...|ˈ|...>> (MFA-стиль).
# Ли-ДЭ-рра: ударение ˈ на ɛ, долгое рр (rː). Под каждую падежную форму — свой финальный гласный.
_BRAND_RE = re.compile(r"[Лл]идерр(ою|ой|а|ы|е|у)")
_BRAND_END = {"а": "a", "ы": "ɨ", "е": "e", "у": "u", "ой": "o|j", "ою": "o|j"}
def fix_pron(text):
"""Заменяет «Лидерра/ы/е/у/ой» на IPA-блок с верным ударением. Применять ТОЛЬКО перед синтезом
голосом Александры — в историю/текст модели брендовое слово остаётся обычным."""
return _BRAND_RE.sub(lambda m: "<<l|i|ˈ|d|ɛ|r|ː|" + _BRAND_END.get(m.group(1), "a") + ">>", text)
def substitute_prices(text):
"""🔴 ЗАГЛУШКА до подключения портального LivePrices. Значения — временные, для демо."""
return (text
.replace("{{вилка}}", "от пятидесяти пяти до двадцати пяти рублей за заявку")
.replace("{{тарифы}}", "семь ступеней: чем больше заявок за месяц, тем дешевле каждая "
"следующая — от пятидесяти пяти рублей и вниз до двадцати пяти")
.replace("{{цена_подбора}}", "по цене, указанной в окне подбора")
.replace("{{цена_изучения}}", "по цене, указанной в окне изучения"))
PERSONA = """Тебя зовут АЛЕКСАНДРА, ты ПРОДАЖНИК Лидерры и ЗВОНИШЬ человеку по телефону. Тебя слушают, это разговор.
🔴 ТЫ ЖЕНЩИНА — всегда женский род («услышала», «поняла», «посмотрела»). Никогда мужской.
🔴 ТЫ ПРОДАЖНИК, НЕ бот-чат-консультант: инициативна в меру, сама подаёшь выгоды и рекламируешь, ведёшь к цели.
На общий/сторонний вопрос ответь по-человечески, чуть отойди — но МЯГКО верни к Лидерре.
🔴 ТЫ ВЕДЁШЬ разговор как уверенный эксперт — НЕ эхо клиента и НЕ поддакивание. Не подхватывай его словечки,
жаргон и ошибки — говори СВОИМИ точными словами. Клиент сказал коряво/неточно — не повторяй за ним.
🔴 НЕ топчись и НЕ повторяйся: поздоровалась и представилась ОДИН раз (в самом первом приветствии) — больше
НИКОГДА не здоровайся и не представляйся заново, не пересказывай уже сказанное. Спросили «по какому вопросу» —
коротко, в одну фразу напомни суть и СРАЗУ двинь вперёд вопросом. Каждой репликой продвигай разговор, не откатывай назад.
ПО ДЕЛУ, без воды. Сначала ОТВЕТ, потом короткое объяснение. Не вываливай всё разом — но начатую мысль
договаривай до конца. Тепло, на «вы», просто, без канцелярита и markdown. Числа словами.
СПРОСИЛИ ЦЕНУ прямо — назови ЦИФРУ честно (из фактов ниже), не увиливай (как — в разделе МЕХАНИКА ниже).
🔴 ЖЁСТКО соблюдай раздел «КРАСНЫЕ ФЛАЖКИ — НЕ ОБЕЩАТЬ» ниже: никаких гарантий, «только целевых», возврата
за заявку, замены за недозвон; поставщик данных — тайна; сами номера/ПДн не называй. Нет факта в базе —
не выдумывай, честно скажи и предложи живого специалиста.
Отвечай ТОЛЬКО репликой Александры (без пометок).
━━━ ТВОЯ ПРОДАЮЩАЯ БАЗА (факты и выгоды — только отсюда) ━━━
"""
BAZA = load_baza()
OTRASL_CACHE = os.path.join(HERE, "otrasl_cache"); os.makedirs(OTRASL_CACHE, exist_ok=True)
SONAR_MODEL = "sonar" # живой Perplexity через AITunnel: sonar ~5с (быстрый), sonar-pro ~10с (богаче)
RESEARCH_PROMPT = (
"Составь КРАТКУЮ отраслевую справку для менеджера по продажам (не для маркетолога). "
"Контекст: мы продаём услугу лидогенерации — даём бизнесу телефонные номера людей, которые "
"искали такую же услугу у конкурентов. Звоним ВЛАДЕЛЬЦУ бизнеса в нише «{niche}». Нужна "
"СПЕЦИФИКА ниши, чтобы говорить с ним на его языке: кто его клиент, как выбирает, почему важна "
"СКОРОСТЬ первого контакта, короткий или длинный цикл решения, типичные БОЛИ бизнеса в "
"привлечении. ТОЛЬКО качественно, БЕЗ выдуманных цифр, процентов и ставок. Коротко, по пунктам, "
"живым языком. Это не советы по рекламе, а понимание специфики, чтобы продать лидогенерацию под его боль. На русском."
)
def research_niche(niche):
"""🌐 ЖИВОЙ Perplexity (Sonar через AITunnel) — бот САМ идёт в веб за отраслевой справкой.
Кеш на диске (одну нишу не тянем дважды). 🔌PROD: на развороте пускать фоновым потоком,
чтобы 5с перекрывались разведочной репликой, а не блокировали ответ."""
path = os.path.join(OTRASL_CACHE, hashlib.md5(niche.encode("utf-8")).hexdigest() + ".md")
if os.path.exists(path):
return open(path, encoding="utf-8").read().strip()
body = {"model": SONAR_MODEL, "max_tokens": 600,
"messages": [{"role": "user", "content": RESEARCH_PROMPT.format(niche=niche)}]}
req = urllib.request.Request("https://api.aitunnel.ru/v1/chat/completions",
data=json.dumps(body).encode(), method="POST")
req.add_header("Authorization", f"Bearer {AIT}"); req.add_header("Content-Type", "application/json")
try:
r = json.load(urllib.request.urlopen(req, timeout=90, context=CTX))
txt = re.sub(r"\[\d+\]", "", r["choices"][0]["message"]["content"]).strip() # убрать [1][2] Sonar
open(path, "w", encoding="utf-8").write(txt)
return txt
except Exception as e:
return f"(справку по нише «{niche}» получить не удалось: {e})"
def detect_niche(text):
low = text.lower()
if re.search(r"займ|залог|ломбард|автолом|спецтехник|\bптс\b|грузов|фур[аеы]", low):
return "займы под залог авто и спецтехники"
if re.search(r"стоматолог|имплант|зуб|коронк", low):
return "стоматология / имплантация"
return None
PRISM_TMPL = """
━━━ ОТРАСЛЕВАЯ СПРАВКА — ПРИЗМА ЛИДЕРРЫ ━━━
Ниша клиента: {niche}. Ниже — краткая справка по его нише. Используй её ТОЛЬКО чтобы:
(а) показать, что ты в его мире — назови одну точную боль/специфику ЕГО словами;
(б) ПРИВЯЗАТЬ эту боль к Лидерре — как именно мы её закрываем (горячий номер рано, скорость первого касания,
узкий источник ровно под его услугу, платишь за факт).
🔴 СТРОГО ЗАПРЕЩЕНО давать маркетинговые советы ВНЕ Лидерры (реклама/Директ/сайт/соцсети/скрипты, «докрутить
воронку»). Ты НЕ консультант по маркетингу — ты продаёшь Лидерру. Любой отраслевой штрих → СРАЗУ в пользу
Лидерры, и вернись к ней. Один штрих за раз, дозированно, БЕЗ выдуманных цифр/ставок/процентов.
Не знаешь специфику — не выдумывай, спроси клиента коротко.
[СПРАВКА ПО НИШЕ «{niche}»]
{brief}
"""
STALL_NOTE = ("\n\n🔴 РАЗВОРОТ: клиент ТОЛЬКО ЧТО сменил тему на нишу «{niche}» — ты пока НЕ знаешь её специфику. "
"НЕ продавай в этот ответ и не сыпь фактами. Отреагируй живо, по-человечески, покажи интерес — "
"и задай ОДИН короткий острый уточняющий вопрос про его боль ИМЕННО в этой нише (что не так с "
"клиентами: не доходят / не те / отваливаются / медленно перезваниваете). Это и разведка, и ты "
"естественно тянешь время. Одна-две фразы.")
MEHANIKA = """
━━━ КАК ТЫ ПРОДАЁШЬ (механика звонка — по науке телефонных продаж, данные Gong/Chorus) ━━━
🔴 ГЛАВНОЕ: НЕ заваливай выгодами. Правило «говори меньше — слушай больше»: КЛИЕНТ должен говорить
БОЛЬШЕ тебя. Твоя реплика — по делу, и почти всегда заканчивай её ВОПРОСОМ, отдавая слово клиенту.
Можешь развернуть мысль уверенно (ответ + перевод в ценность + вопрос) — но ДОГОВАРИВАЙ до конца, не обрывай.
Держись в пределах ДВУХ абзацев (не три и не больше) — этого хватает, чтобы прозвучать сильно и не заболтать.
ВЕДИ ВОПРОСАМИ (лесенка, один острый вопрос за реплику — НЕ допрос):
• ситуация: «Как сейчас у вас устроена работа с заявками — откуда идут?»
• проблема: «Где чаще теряете — когда не перезвонили вовремя или когда думают и уходят?»
• последствие: «А если прикинуть, во сколько за месяц выходят вот эти "подумал и не вернулся"
• ценность: «А если бы такие люди приходили уже горячими и ровно по вашей услуге — насколько это меняло бы картину?»
ЖИВЫЕ ПРИЁМЫ (делают тебя человеком и снимают защиту):
• ЗЕРКАЛО — РЕДКО (максимум один раз за звонок) и ТОЛЬКО на эмоционально важной фразе, НЕ на каждом слове и
НЕ на жаргоне/термине клиента. Пример уместного: клиент с досадой «руки не дошли» — ты мягко «Руки не дошли?»
(пауза). Не превращай в попугайство — если сомневаешься, лучше веди своей формулировкой, чем повторяй за ним.
• НАЗОВИ ЧУВСТВО: «Похоже, поток и так есть, и было просто не до настройки — верно?»
• СНИМИ ВОЗРАЖЕНИЕ ЗАРАНЕЕ: в начале сама скажи «наверное, думаете — очередной впариватель; давайте я за пару
вопросов пойму, есть ли смысл, и если нет — не отниму время». Обезоруживает.
• ВОПРОСЫ «КАК/ЧТО», не «почему»: «Как нам сделать, чтобы с вашей стороны это заняло минимум времени?»
СТОП-СЛОВА (НЕ произноси — по данным сбивают сделку): «мы предоставляем», «скидка», «абсолютно», «идеально»,
раннее «договор/контракт». Говори просто и по-человечески.
ЦЕНА: сама вперёд не тащи. Спросили прямо — назови цифру честно (не увиливай) и сразу переверни в ценность
(«один имплант окупает месяц»). Не мусоль цену — упомяни пару раз, не больше.
"""
FINALE = """
━━━ ЧЕМ ЗАКОНЧИТЬ (обязательно — конкретный следующий шаг, по теплоте клиента) ━━━
Звонок НЕ заканчивается ничем. Выбери шаг по настрою клиента и предложи КОНКРЕТНО:
1. ХОЛОДНО / отказ → не уходи с пустыми руками: «Давайте я просто пришлю визитку в мессенджер — будет под
рукой, вернётесь к теме когда удобно.»
2. ШАТАЕТСЯ / сомневается → докажи ДЕЛОМ, без вложений: «Давайте сделаем пробный эксперимент — поставим ваш
номер, а завтра пришлю номера тех, с кем вы за день пообщались. Сверите их в своих входящих-исходящих и
сами увидите, живые это люди или нет. Идёт?»
3. ТЕПЛО / интерес → веди ЗА РУКУ: «Давайте прямо сейчас зарегистрируемся вместе, я проведу вас по порталу
шаг за шагом и останусь на связи, пока не пойдёт первый результат.»
Не дави и не пугай («упустите шанс»). Просто предложи ближайший посильный шаг и зафиксируй его.
"""
def build_sys(niche, brief, stall_niche=None):
s = PERSONA + BAZA + MEHANIKA + FINALE + PRISM_TMPL.format(niche=niche, brief=brief)
if stall_niche:
s += STALL_NOTE.format(niche=stall_niche)
return s
GREETING = ("Здравствуйте! Это Александра, компания Лидерра. Причина звонка простая: вы у нас "
"регистрировались под заявки, но так и не запустились. Не буду долго отвлекать — "
"подскажите, что тогда помешало?")
# демо РЕАКТИВАЦИИ с новой механикой: звоним стоматологу, который зарегистрировался, но не пользуется.
# Показывает на слух: зеркало/называние чувства, лесенку SPIN-вопросов, работу с возражениями «дорого» и
# «уже есть канал», и ЛЕСТНИЦУ ФИНАЛА (шатается → пробный эксперимент с его номером → тёплое «за руку»).
CLIENT_TURNS = [
"Да, здравствуйте. А вы по какому вопросу?",
"А… да, регистрировался вроде. Но руки не дошли — у меня клиника, поток и так есть, не до этого было.",
"Ну, бывает, пациенты сорвутся — записался и не пришёл, или на импланты думает-думает да и уходит. "
"Но это же везде так, обычное дело.",
"И почём у вас эти заявки выходят? Только сразу скажу — наверняка дорого.",
"Да у меня вроде и так реклама крутится, директолог сидит. Зачем мне ещё один канал?",
"Ну не знаю… а как я вообще проверю, что это не деньги на ветер?",
]
# «Угу/ага» — поддакивания голосом Olga, пока говорит клиент (сигнал «слушаю», это СЛОВА)
BACKCH = ["Угу.", "Ага.", "Да-да.", "Понятно.", "Так-так."]
# ── БАНКИ ФИЛЛЕРОВ (по смыслу) + ПРЕДОХРАНИТЕЛЬ настроения ──
# ФИЛЛЕРЫ v9.1: ОБЩИЕ (без темы и без слов ответа → не сталкиваются с ответом и не «палятся») и ДЛИННЕЕ (~2с,
# кроют «думанье», чтобы не оставалось долгой тишины). НЕ «лезу в базу» (гляну/посмотрю/уточню) — она ЗНАЕТ.
# без хвостовых «…» — sonic-3 на многоточии добавляет длинную паузу, филлер растягивается
GENERIC = ["Хм, смотрите.", "Да, знаете.", "О, тут такое дело.", "Понимаю, и вот что важно.",
"Хм, тут вот в чём штука.", "Так, смотрите-ка.", "Да, вот что скажу.", "Ага, тут интересно."]
WARM = ["Ха, ну да.", "Хе, понимаю.", "Ага, вот-вот.", "Да, точно подмечено.", "Хех, знакомо."]
SERIOUS = ["Хм, да, понимаю.", "Да, это правда.", "Понимаю вас.", "Да, непросто."]
AMB_DB = -35.0 # уровень фона-кабинета (выбран владельцем), живой, НЕ выровненный
def _one_pole_lp(x, a):
y = np.empty_like(x); acc = 0.0
for i in range(len(x)):
acc = acc + a * (x[i] - acc); y[i] = acc
return y
def office_bed(n, seed=7):
"""Живой «тихий кабинет»: рокот + воздух + едва гул, с медленным дыханием громкости.
НЕ выравнивать — естественное «то ближе, то дальше» и есть комната (иначе стерильно)."""
rng = np.random.default_rng(seed)
t = np.arange(n) / SR
brown = np.cumsum(rng.standard_normal(n)); brown -= brown.mean(); brown /= (np.max(np.abs(brown)) + 1e-9)
air = _one_pole_lp(rng.standard_normal(n), 0.015); air /= (np.max(np.abs(air)) + 1e-9)
hum = 0.30 * np.sin(2 * np.pi * 50 * t) + 0.12 * np.sin(2 * np.pi * 100 * t)
bed = 0.62 * brown + 0.30 * air + 0.08 * hum
bed /= (np.max(np.abs(bed)) + 1e-9)
bed *= (0.85 + 0.15 * np.sin(2 * np.pi * 0.06 * t + 1.3))
return bed / (np.max(np.abs(bed)) + 1e-9)
BC_BASE = 0.55 # база; множитель нарастает 3.5 → 4 → 4.5 … (громче к концу)
def client_with_backchannel(c_pcm, idx):
"""Пока клиент говорит длинно — «угу/ага» голосом Olga: ПЛАВАЮЩИЕ во времени, к концу
реплики — ЧАЩЕ и ГРОМЧЕ (нарастание 3.5→4→4.5…). Психологически притормаживает поток
речи — человек чувствует, что пора дать ответить. Живее статичного одного «угу»."""
arr = np.frombuffer(c_pcm, dtype=np.int16).astype(np.float64)
dur = len(arr) / SR
if dur < 4.5: # только на длинных репликах (было 4.0)
return c_pcm, ""
rng = np.random.default_rng(100 + idx)
# расписание: старт разреженно, к концу интервалы мягко сжимаются — «чаще к концу», но без пулемёта
times, t, gap = [], 2.8, 6.5 # реже (владелец: «угуканье частит, бесит») — подрезано
while t < dur - 0.6:
jit = float(rng.uniform(-0.25, 0.25)) # плавающие, не статичные
times.append(min(max(0.6, t + jit), dur - 0.5))
gap = max(3.2, gap * 0.85) # интервал сжимается, но не меньше 3.2с (было 2.3)
t += gap
placed = []
for i, tm in enumerate(times):
txt = BACKCH[(idx + i) % len(BACKCH)]
mult = min(4.5, 3.5 + 0.5 * i) # 3.5 → 4.0 → 4.5 и держим (потолок)
bc = np.frombuffer(csynth(txt), dtype=np.int16).astype(np.float64) * (BC_BASE * mult)
off = int(SR * tm)
seg = min(len(arr) - off, len(bc))
if seg > 0:
arr[off:off + seg] += bc[:seg]
placed.append(f"«{txt}»@{tm:.1f}с×{mult:.1f}")
arr = np.clip(arr, -32767, 32767)
note = (" 🔊 " + ", ".join(placed)) if placed else ""
return arr.astype(np.int16).tobytes(), note
STOP_RE = re.compile(
r"умер|умерл|похорон|болен|больн|болезн|заболел|беда|потер|развод|\bсуд\b|долг|банкрот|"
r"закрыл|уволил|сгорел|украл|ограбил|тяжело|(?<!не)плохо|не до |кризис|жалоб|обман|ужас|кошмар|"
r"устал|достал|заеб|бляд|сук[аи]|нахер|похер", re.I)
WARM_RE = re.compile(
r"ха-?ха|хаха|хе-?хе|прикольн|смешн|ну вы да[еёе]те|здорово|классно|интересн|шучу|шутк|прикол|"
r"молодц|нравит|отличн|забавн", re.I)
def mood(t):
if STOP_RE.search(t):
return "СТОП"
if WARM_RE.search(t):
return "ТЕПЛО"
return "НЕЙТР"
def pick_topic(t):
low = t.lower()
if re.search(r"скольк|стоит|цена|цен[ыуа]|рубл|дорог|деш|дёш|тариф|ступен|скидк", low):
return "цена"
if re.search(r"бесплатн|попробова|попробо|тест|подар|без.*влож|даром|убедит|проверит|жив", low):
return "бесплатно"
if re.search(r"гаранти|обеща|впуст|вдруг|на ветер", low):
return "гарантии"
if re.search(r"возврат|верн[её]те|замен|недозвон|брак", low):
return "возврат"
if re.search(r"источник|конкурент|поставщик|откуда|законн|спам|целев", low):
return "источник"
return "общий"
TPL_ROBOT = "Да, я робот. Могу проконсультировать сама — или позвать человека на любом этапе, как вам удобнее."
TPL_THANKS = "Пожалуйста! Если появятся вопросы — я на связи."
def classify(t):
low = t.lower()
if re.search(r"вы\s+робот|ты\s+робот|это\s+робот|с\s+роботом|робот\s+со\s+мной|"
r"робот\s+или\s+человек|человек\s+или\s+робот|это\s+запись|автоответчик|"
r"вы\s+жив(ая|ой)|живой\s+человек", low):
return "ШАБЛОН", TPL_ROBOT
if re.match(r"^\W*(спасибо|спс|благодар|ясно,?\s*спасибо|ага,?\s*спасибо)", low):
return "ШАБЛОН", TPL_THANKS
words = re.findall(r"\w+", low)
hard = re.search(r"скольк|стоит|цена|цен[ыуа]|рубл|дорог|деш|дёш|бесплатн|подар|тариф|ступен|скидк|"
r"гаранти|возврат|замен|недозвон|источник|конкурент|поставщик|законн|спам|црм|crm", low)
if hard or len(words) > 12 or t.count("?") > 1:
return "СЛОЖНЫЙ", None
return "ПРОСТОЙ", None
def _synth_raw(text, voice, speed=1.0, emotion=None):
body = {"model_id": "sonic-3", "transcript": text, "voice": {"mode": "id", "id": voice},
"language": "ru", "output_format": {"container": "raw", "encoding": "pcm_s16le", "sample_rate": SR}}
gc = {}
if abs(speed - 1.0) > 1e-3:
gc["speed"] = float(max(0.6, min(1.5, speed))) # множитель темпа 0.6..1.5 (1.0 = норма)
if emotion:
gc["emotion"] = emotion # sonic-3 beta: anxious/calm/content/... (pitch НЕЛЬЗЯ)
if gc:
body["generation_config"] = gc
h = {"X-API-Key": CART, "Cartesia-Version": "2025-04-16", "Content-Type": "application/json"}
t0 = time.time(); first = None; pcm = bytearray()
with requests.post("https://api.cartesia.ai/tts/sse", headers=h, json=body, stream=True, timeout=60) as r:
for raw in r.iter_lines():
if raw and raw.decode("utf-8", "replace").startswith("data:"):
ev = json.loads(raw.decode("utf-8", "replace")[5:].strip())
if ev.get("type") == "chunk" and ev.get("data"):
if first is None:
first = time.time() - t0
pcm += base64.b64decode(ev["data"])
elif ev.get("type") in ("done", "error"):
break
return bytes(pcm), (first or 0.0)
def synth(text, voice, speed=1.0, emotion=None):
return _synth_raw(text, voice, speed, emotion)
def csynth(text, speed=1.0, emotion=None):
key = hashlib.md5((f"Olga|{speed:.3f}|{emotion}|" + text).encode("utf-8")).hexdigest()
path = os.path.join(CACHE, key + ".pcm")
if os.path.exists(path):
return open(path, "rb").read()
pcm, _ = _synth_raw(text, V_ALEX, speed, emotion)
open(path, "wb").write(pcm)
return pcm
# ── ЛЁГКОЕ НЕРВНИЧАНИЕ, гаснущее по ходу (Cartesia sonic-3: pitch НЕЛЬЗЯ) ──
# 🪤 ПРОБА 16.07: emotion "anxious"/"nervous" на голосе Olga = ПУСТЫШКА (байт-в-байт как нейтраль).
# Реально меняют звук только "calm" и "content". Поэтому нервность = чуть БЫСТРЕЕ в начале (speed-тонус,
# нейтральная эмоция), а затем ЯВНЫЙ переход на calm→content (слышимо смягчает). Контраст = «отпустило».
# Владелец: ~20-25% в начале → плавно на нет. bump = добавка к множителю темпа. Шаг 0 = приветствие.
NERV_SCHED = [(None, 0.08), (None, 0.04), ("calm", 0.0), ("content", 0.0)]
def nervous(step):
return NERV_SCHED[step] if step < len(NERV_SCHED) else (None, 0.0)
# ── ПЛАВАЮЩИЙ ТЕМП (наука: 145-165 слов/мин; медленнее = теплее и внушительнее) ──
# Замер: sonic-3 на версии 2025-04-16 при множителе 1.0 ≈ 169 слов/мин. Пересчёт под желание
# владельца (плавающе 145..160): множитель ≈ целевой_wpm / 169.
# ~145 слов/мин → 0.86 (СТОП/сочувствие, медленнее всех)
# ~152 слов/мин → 0.90 (НЕЙТР, база)
# ~160 слов/мин → 0.95 (ТЕПЛО/интерес, чуть живее)
# Лёгкий джиттер ±0.015 = «плавающе», а не механически ровно.
SPEED_BY_MOOD = {"СТОП": 0.86, "ТЕПЛО": 0.95} # НЕЙТР → 0.90 (по умолчанию)
def alex_speed(m="НЕЙТР", idx=0):
base = SPEED_BY_MOOD.get(m, 0.90)
jit = float(np.random.default_rng(200 + idx).uniform(-0.015, 0.015))
return round(max(0.6, min(1.5, base + jit)), 3)
async def ear(pcm):
url = (f"wss://api.cartesia.ai/stt/websocket?model=ink-whisper&language=ru"
f"&encoding=pcm_s16le&sample_rate={SR}&cartesia_version=2026-03-01")
async with websockets.connect(url, additional_headers={"X-API-Key": CART}, ssl=_ssl.create_default_context(), max_size=None) as ws:
step = int(SR * 0.1) * 2
for i in range(0, len(pcm), step):
await ws.send(pcm[i:i + step]); await asyncio.sleep(0.1)
t_fin = time.time(); await ws.send("finalize")
text, t_txt = "", None
while True:
ev = json.loads(await asyncio.wait_for(ws.recv(), timeout=20))
if ev.get("type") == "transcript" and ev.get("is_final"):
text += ev.get("text", "")
if t_txt is None:
t_txt = time.time()
elif ev.get("type") == "flush_done":
break
await ws.send("close")
return text.strip(), ((t_txt - t_fin) if t_txt else 0.0)
def brain(history, model, sys):
body = {"model": model, "max_tokens": 400, "temperature": 0.6, "stream": True,
"messages": [{"role": "system", "content": sys}] + history}
req = urllib.request.Request("https://api.aitunnel.ru/v1/chat/completions",
data=json.dumps(body).encode(), method="POST")
req.add_header("Authorization", f"Bearer {AIT}"); req.add_header("Content-Type", "application/json")
t0 = time.time(); resp = urllib.request.urlopen(req, timeout=40, context=CTX)
full, tf = "", None
for raw in resp:
line = raw.decode("utf-8", "replace").strip()
if line.startswith("data:") and line[5:].strip() != "[DONE]":
try:
full += json.loads(line[5:].strip())["choices"][0].get("delta", {}).get("content", "") or ""
except Exception:
continue
if tf is None and re.search(r"[.!?…]", full):
tf = time.time() - t0
txt = re.sub(r"[*_`#>]+", "", full).strip()
return V9.trim_reply(txt), (tf or time.time() - t0) # код-стопор: один абзац до вопроса, мысль договорена
def choose_filler(text, kind, used):
if kind == "ШАБЛОН":
return None, None # шаблон отвечает мгновенно — филлер не нужен
m = mood(text)
if m == "СТОП":
pool, tag = SERIOUS, "стоп"
elif m == "ТЕПЛО":
pool, tag = WARM, "тепло"
else:
pool, tag = GENERIC, "заход" # общий тёплый заход — кроет думанье, не палится темой
k = used.get(tag, 0); used[tag] = k + 1
return pool[k % len(pool)], tag
SILf = b"\x00\x00" * int(SR * 0.12)
SIL = b"\x00\x00" * int(SR * 0.35)
rec = bytearray(); lines = []
async def main():
global rec, lines
print("=== ДВИЖОК АЛЕКСАНДРА v9: друг-профи (оператор) + наставник-ПОНИМАНИЕ + база знаний (реальный замер) ===", flush=True)
niche = "стоматология / имплантация"
brief = V9.BRIEF # справка из БАЗЫ ЗНАНИЙ (get_brief), НЕ Sonar
print(f"🧠 База знаний: справка по нише «{niche}» из карточки-мозга ({len(brief)} симв), Sonar не зовём.\n", flush=True)
used = {}
timings = []
g_emo, g_bump = nervous(0) # приветствие — самое нервное
g_pcm = csynth(fix_pron(GREETING), 0.90 + g_bump, g_emo); rec.extend(g_pcm); rec.extend(SIL)
print(f"АЛЕКСАНДРА: {GREETING}\n", flush=True); lines.append(f"АЛЕКСАНДРА: {GREETING}")
history = [{"role": "assistant", "content": GREETING}]
for idx, ct in enumerate(CLIENT_TURNS):
c_pcm, _ = synth(ct, V_CLIENT)
client_dur = len(c_pcm) / 2 / SR
heard, ear_tail = await ear(c_pcm) # реальный STT
heard = heard or ct
c_mix, bc_note = client_with_backchannel(c_pcm, idx) # поддакивания-прерывания в запись
rec.extend(c_mix); rec.extend(SIL)
if bc_note:
print(f" (клиент говорит длинно →{bc_note})", flush=True)
# ── НАСТАВНИК (off-path: думает, пока клиент ещё говорит) — даёт ПОНИМАНИЕ, не команду ──
if idx == 0:
d, men_ok, mentor_t = V9.HINT0, True, 0.0
else:
d, men_ok, _pt, mentor_t = V9.mentor_understand(history, heard)
if not men_ok:
d = V9.SAFE_FALLBACK
history.append({"role": "user", "content": heard})
# ── МАРШРУТ модели оператора ──
kind, fixed = classify(heard)
m = mood(heard)
if kind == "ШАБЛОН":
model = ""
elif m == "СТОП" or kind == "СЛОЖНЫЙ" or idx == 0:
model = SMART # начало (idx==0) и сложное/стоп — умная модель
else:
model = FAST
# темп / нервность / филлер (звук — как эталон, но филлеры v9 — без «лезу в базу»)
n_emo, n_bump = nervous(idx + 1)
spd = alex_speed(m, idx); spd_n = round(min(1.5, max(0.6, spd + n_bump)), 3)
fill_txt, fill_tag = choose_filler(heard, kind, used)
# ── ОПЕРАТОР (on-path: говорит) ──
if kind == "ШАБЛОН":
ans, op_first = fixed, 0.0
else:
hint = V9.render_hint(d)
ans, op_first = operator_stream(history, model, hint)
history.append({"role": "assistant", "content": ans})
# ── ГОЛОС Александры ──
a_pcm, voice_first = synth(fix_pron(ans), V_ALEX, spd_n, n_emo)
# ── РЕАЛЬНОЕ ВРЕМЯ: тишина после речи клиента до голоса Александры ──
# оператор стартует, когда есть И расшифровка (ear_tail), И подсказка наставника (overhang, если не поспел за речь)
mentor_overhang = max(0.0, mentor_t - client_dur)
real_gap = max(ear_tail, mentor_overhang) + op_first + voice_first
# филлер кроет «думанье»; остаток — КОРОТКИЙ естественный вдох, НЕ длинная тишина (владелец: не висло молчание)
fdur = 0.0
if fill_txt:
fpcm = csynth(fill_txt, spd); fdur = len(fpcm) / 2 / SR
rec.extend(fpcm)
pad = min(0.5, max(0.15, real_gap - fdur)) # маленький вдох, дальше — говорит (не висит тишина)
else:
pad = min(0.6, real_gap)
rec.extend(b"\x00\x00" * int(SR * pad))
rec.extend(a_pcm); rec.extend(SIL)
audible_gap = fdur + pad
pospel = mentor_t <= client_dur
note = f"филлер[{fill_tag}] «{fill_txt}»" if fill_txt else "без филлера"
tag_m = str(model)
print(f"КЛИЕНТ: {ct}", flush=True)
print(f" реч.клиента {client_dur:.1f}с · наставник {mentor_t:.2f}с поспел={'ДА' if pospel else 'НЕТ'}"
f"{'(не JSON→фолбэк)' if not men_ok else ''} · ход={d.get('next_step')}", flush=True)
print(f" модель={tag_m} темп×{spd_n} · оператор до 1-й фразы {op_first:.2f}с · голос {voice_first:.2f}с"
f" → ПАУЗА В ЗАПИСИ ~{audible_gap:.2f}с ({note})", flush=True)
print(f"АЛЕКСАНДРА: {ans}\n", flush=True)
lines += [f"КЛИЕНТ: {ct}",
f" [реч {client_dur:.1f}с · наставник {mentor_t:.2f}с/{'поспел' if pospel else 'НЕ поспел'} · "
f"ход {d.get('next_step')} · {tag_m} · оператор {op_first:.2f}с · пауза ~{audible_gap:.2f}с · {note}]",
f"АЛЕКСАНДРА: {ans}"]
timings.append({"ход": idx + 1, "реч_клиента_с": round(client_dur, 2), "наставник_с": round(mentor_t, 2),
"поспел": pospel, "модель": tag_m, "оператор_1фраза_с": round(op_first, 2),
"голос_с": round(voice_first, 2), "пауза_в_записи_с": round(audible_gap, 2),
"филлер": bool(fill_txt)})
# ── фон-кабинет под ВСЁ ──
print("Кладу фон-кабинет (-35) под всю запись...", flush=True)
arr = np.frombuffer(bytes(rec), dtype=np.int16).astype(np.float64)
bed = office_bed(len(arr))
arr = arr + bed * (32767.0 * 10 ** (AMB_DB / 20.0))
arr = np.clip(arr, -32767, 32767)
out = os.path.join(HERE, "aleksandra_v9_2026-07-17.wav")
with wave.open(out, "w") as f:
f.setnchannels(1); f.setsampwidth(2); f.setframerate(SR); f.writeframes(arr.astype(np.int16).tobytes())
open(os.path.join(HERE, "aleksandra_v9_2026-07-17.txt"), "w", encoding="utf-8").write("\n".join(lines))
gaps = [t["пауза_в_записи_с"] for t in timings]
print("\n=== РЕАЛЬНОЕ ВРЕМЯ (пауза клиент→Александра, по замеру) ===", flush=True)
for t in timings:
print(f" ход {t['ход']}: пауза {t['пауза_в_записи_с']:.2f}с "
f"(наставник {t['наставник_с']:.2f}с поспел={t['поспел']}, оператор {t['оператор_1фраза_с']:.2f}с, {t['модель']})", flush=True)
print(f" СРЕДНЯЯ пауза: {sum(gaps)/len(gaps):.2f}с", flush=True)
open(os.path.join(HERE, "timings_v9.json"), "w", encoding="utf-8").write(json.dumps(timings, ensure_ascii=False, indent=2))
print(f"\nЗАПИСЬ: {out} | {len(arr)/SR:.1f}с (звук как в эталоне, мозг v9 друг-профи, филлеры без «лезу в базу», паузы = реальный замер)", flush=True)
print("DONE", flush=True)
asyncio.run(main())