7d0c06ca51
Системный разворот характера (по разбору владельца, что 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>
631 lines
46 KiB
Python
631 lines
46 KiB
Python
# -*- 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())
|