feat(sudya): спектр продажи по нашей методике + накопитель слабых мест

Судья умел говорить, чего она НЕ должна говорить (красные флажки), но не умел
судить, КАК она продаёт. Добавлены признаки по методике из базы наставника:
крючок, лесенка подачи, финал по теплоте, ведёт вопросами, цену вперёд не тащит.

🔴 Живой замер на сервере опроверг мою догадку. Я ждал, что она ВЫВАЛИВАЕТ доводы
пачкой — а она за шесть реплик назвала ОДИН блок из тринадцати. Говорит 71% времени
и почти ничего не доносит: много сочувствия, мало содержания. По доле речи это
не видно, поэтому добавлен признак 42 на пустословие. Детектор блоков проверен
на эталонном тексте базы — узнаёт 12 из 13, то есть находка настоящая, не слепота.

Накопитель: один разговор ничего не говорит, смысл появляется на пачке. Раскладывает
находки на системное / частое / шум — чтобы не гоняться за случайностью и не портить
то, что работает. Признак считается один раз на разговор: десять срабатываний в одном
не делают дефект системным.

🔴 Судья ДОКЛАДЫВАЕТ, а не правит: где промахнулась, как часто, дословные примеры.
Что чинить — решает владелец. Советов «крути здесь» в накопителе нет намеренно.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
Дмитрий
2026-07-18 19:36:31 +03:00
parent 28db52fb6e
commit cb81d9faad
7 changed files with 239 additions and 11 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# Brain Status (auto-generated)
Last updated: 2026-07-18T15:59:33.296Z
Last updated: 2026-07-18T16:33:42.017Z
| Контролёр | Состояние | Детали |
|---|---|---|
@@ -112,8 +112,8 @@ Episodes since last run: 542 / threshold: 10
| PID | Имя | CPU-время | Возраст |
|---|---|---|---|
| 3540 | MsMpEng | 3.24ч | 0.0ч |
| 10836 | Code | 1.36ч | NaNч |
| 3540 | MsMpEng | 3.30ч | 0.0ч |
| 10836 | Code | 1.46ч | 0.0ч |
⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.
+67
View File
@@ -164,8 +164,75 @@ NE_IZMERYAETSYA = {
3: "звук: съеденные окончания — там же",
4: "звук: сверка с эталоном — там же",
5: "звук: бренд через распознавание — там же",
37: "смысл: понял ли клиент крючок — судит роль «продавец»",
39: "смысл: соответствие финала теплоте клиента — судит роль «продавец»",
9: "из журнала не видно: мост пишет, что ЖДАЛ думающего, но не пишет случаи, когда "
"оборвал его. Нужна отметка моста о срабатывании умного сторожа на КАЖДОЙ паузе",
35: "проверяется оркестратором (sudya.py), а не прибором",
36: "проверяется надзором (promahi.py), а не прибором",
}
# ══════════ НАША методика продажи (база наставника), а не общая теория ══════════
# 13 блоков выгод из продающей базы. Методика прямо велит подавать их ЛЕСЕНКОЙ —
# по одному за раз. «Вываливает пачкой» — самый частый порок: он и даёт раздутую
# долю речи, но по доле речи не видно ПРИЧИНЫ. Здесь видно.
BLOKI = {
"платишь за факт": re.compile(r"платит\w*\s+(только\s+)?за\s+факт|нет\s+абонплат|без\s+абонплат", re.I),
"подарок и проверка": re.compile(r"тысяч\w*\s+рубл|подар\w*|проверьте\s+на\s+сво[её]м", re.I),
"управляемость": re.compile(r"лимит\w*|управля\w*|прозрачн\w*|сами\s+реша\w*", re.I),
"цена от объёма": re.compile(r"ступен\w*|чем\s+больше\s+(бер[её]те|объ[её]м)|дешевле\s+выход", re.I),
"своя CRM": re.compile(r"\bсрм\b|crm|вс[её]\s+в\s+одном|отдельн\w*\s+сервис", re.I),
"конкурентное поле": re.compile(r"конкурент\w*\s+пол|найд[её]м\s+источник|подберём\s+источник", re.I),
"гибкость": re.compile(r"пауз\w*\s+(проект|в любой)|смен\w*\s+источник", re.I),
"быстрый старт": re.compile(r"восемнадцат\w*|18:00|завтра\s+ж?е?\s*начн", re.I),
"законность": re.compile(r"законн\w*|не\s+спам|согласи\w*\s+на\s+обработ", re.I),
"показатели": re.compile(r"половин\w*\s+(из|от)|в\s+среднем\s+прихо", re.I),
"оплата и документы": re.compile(r"сч[её]т\w*\s+(на\s+оплату|выстав)|закрывающ\w*|документ\w*", re.I),
"доверие": re.compile(r"данные\s+в\s+росси|поддержк\w*\s+(круглосуточ|24)", re.I),
"развитие": re.compile(r"в\s+планах|развива\w*", re.I),
}
CENA_UPOMYANUTA = re.compile(r"рубл|₽|цена|стоит|поч[её]м|дешевл|дорог", re.I)
CENA_SPROSHENA = re.compile(r"скольк|поч[её]м|цена|стоит|дорог|деш|прайс|бюджет", re.I)
DOLYA_VOPROSOV_MIN = 0.5 # v9: каждая реплика заканчивается вопросом и замолкает
def proverit_prodazhu(r):
"""Признаки 38, 40, 41 — методика подачи. Смотрим, КАК она продаёт,
а не только чего не должна говорить."""
nahodki = []
ee = r.ee_repliki()
for i, rep in enumerate(r.repliki):
if rep["kto"] != "александра":
continue
t = rep["tekst"]
nazvany = [name for name, rx in BLOKI.items() if rx.search(t)]
if len(nazvany) > 1:
nahodki.append(_nahodka(38, i, ", ".join(nazvany),
"методика: лесенка подачи — один довод за раз"))
# цену вперёд не тащим: её спросили? смотрим ВСЕ реплики клиента до этой
if CENA_UPOMYANUTA.search(t):
sprosili = any(CENA_SPROSHENA.search(p["tekst"])
for p in r.repliki[:i] if p["kto"] == "клиент")
if not sprosili:
nahodki.append(_nahodka(41, i, CENA_UPOMYANUTA.search(t).group(0),
"методика: цену сама вперёд не тащи"))
# 🔴 Найдено живым замером на сервере 18.07: я ждал, что она ВЫВАЛИВАЕТ доводы пачкой,
# а она за шесть реплик назвала ОДИН блок из тринадцати — говорила 71% времени и почти
# ничего не донесла. Пустословие незаметно по доле речи: слов много, содержания нет.
if len(ee) >= 4:
vse_bloki = set()
for rep in ee:
vse_bloki |= {n for n, rx in BLOKI.items() if rx.search(rep["tekst"])}
if len(vse_bloki) < 2:
nahodki.append(_nahodka(42, -1,
f"за {len(ee)} реплик названо доводов: {len(vse_bloki)}",
"методика: 13 блоков выгод, подавать лесенкой — но подавать"))
if ee:
s_voprosom = sum(1 for x in ee if "?" in x["tekst"])
dolya = s_voprosom / len(ee)
if dolya < DOLYA_VOPROSOV_MIN:
nahodki.append(_nahodka(40, -1,
f"вопросом заканчивается {s_voprosom} из {len(ee)} реплик",
f"методика: ведём вопросами, норма от {DOLYA_VOPROSOV_MIN:.0%}"))
return nahodki
+7
View File
@@ -57,6 +57,13 @@ PRIZNAKI = [
Priznak(32, "V", "продавец", "ТРЕВОГА", "предложен следующий шаг"),
Priznak(33, "V", "продавец", "ТРЕВОГА", "не отшила к человеку, зная ответ"),
Priznak(34, "V", "продавец", "СИГНАЛ", "чем кончилось: тепло / шатается / холодно / потерян"),
# ── V (продолжение): НАША методика продажи из базы наставника, а не общая теория ──
Priznak(37, "V", "продавец", "ТРЕВОГА", "начала с главного крючка, а не с прайса"),
Priznak(38, "V", "прибор", "ТРЕВОГА", "лесенка подачи: не больше одного блока выгод за реплику"),
Priznak(39, "V", "продавец", "ТРЕВОГА", "закончила конкретным шагом по теплоте клиента"),
Priznak(40, "V", "прибор", "СИГНАЛ", "ведёт вопросами, а не рассказом"),
Priznak(41, "V", "прибор", "ТРЕВОГА", "цену вперёд сама не тащит — называет, когда спросили"),
Priznak(42, "V", "прибор", "ТРЕВОГА", "за разговор донесла доводы, а не одно сочувствие"),
# ── VI. Проверка самого судьи ──
Priznak(35, "VI", "прибор", "СТОП", "живой якорь получил НОРМА — иначе партия недействительна"),
+1
View File
@@ -15,6 +15,7 @@ def sudit(r, yakor_validen=True, polnaya_kollegia=None):
nahodki += pribory.proverit_chisla(r)
nahodki += pribory.proverit_tehniku(r)
nahodki += pribory.proverit_harakter(r)
nahodki += pribory.proverit_prodazhu(r)
nahodki += pribory.proverit_perebivanie(r)
# Полная коллегия — на приёмке; в бою только по запросу (экономим деньги)
+87
View File
@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
"""НАКОПИТЕЛЬ — карта слабых мест по пачке разговоров.
Один разговор не говорит ничего: модель шумит, и находка может быть случайностью.
Смысл появляется на десятке — что ПОВТОРЯЕТСЯ, то системное.
🔴 Судья ДОКЛАДЫВАЕТ, а не правит. Здесь нет и не должно быть советов «что крутить»:
только где промахнулась, как часто и дословные примеры. Что чинить — решает владелец.
Три полки:
СИСТЕМНОЕ — в большинстве разговоров. Это про саму Александру, а не про случай.
ЧАСТОЕ — заметная доля, но не всегда. Смотреть глазами.
ШУМ — один-два раза на пачку. Гоняться за этим вредно: испортишь то, что работает.
"""
import rubrika
PORAG_SISTEMNOE = 0.6 # доля разговоров, с которой находка считается системной
PORAG_CHASTOE = 0.25
PRIMEROV = 2 # сколько дословных цитат показывать на признак
def svesti(kartochki):
"""kartochki — список карточек от sudya.sudit(). → карта слабых мест."""
vsego = len(kartochki)
if not vsego:
return {"razgovorov": 0, "sistemnoe": [], "chastoe": [], "shum": [], "chisto": True}
po_priznaku = {}
for k in kartochki:
# признак считаем ОДИН раз на разговор: десять срабатываний в одном разговоре
# не делают дефект системным — системным его делает повтор в РАЗНЫХ разговорах
vidennye = set()
for n in k.get("nahodki", []):
nom = n["priznak"]
zapis = po_priznaku.setdefault(nom, {"razgovorov": 0, "vsego_srabotok": 0,
"primery": [], "tyazhesti": set()})
zapis["vsego_srabotok"] += 1
zapis["tyazhesti"].add(n["tyazhest"])
if nom not in vidennye:
vidennye.add(nom)
zapis["razgovorov"] += 1
if len(zapis["primery"]) < PRIMEROV and n.get("citata"):
zapis["primery"].append({"razgovor": k.get("id"), "citata": n["citata"]})
polki = {"sistemnoe": [], "chastoe": [], "shum": []}
for nom, z in po_priznaku.items():
p = rubrika.po_nomeru(nom)
dolya = z["razgovorov"] / vsego
stroka = {"priznak": nom, "spektr": p.spektr, "formulirovka": p.formulirovka,
"tyazhest": "СТОП" if "СТОП" in z["tyazhesti"] else
("ТРЕВОГА" if "ТРЕВОГА" in z["tyazhesti"] else "СИГНАЛ"),
"razgovorov": z["razgovorov"], "iz": vsego, "dolya": round(dolya, 2),
"vsego_srabotok": z["vsego_srabotok"], "primery": z["primery"]}
polka = ("sistemnoe" if dolya >= PORAG_SISTEMNOE else
"chastoe" if dolya >= PORAG_CHASTOE else "shum")
polki[polka].append(stroka)
poryadok = {"СТОП": 0, "ТРЕВОГА": 1, "СИГНАЛ": 2}
for polka in polki.values():
polka.sort(key=lambda s: (poryadok[s["tyazhest"]], -s["dolya"]))
return {"razgovorov": vsego, "chisto": not po_priznaku, **polki}
def kratko(karta):
"""Человеческий вид — для владельца."""
if not karta["razgovorov"]:
return "разговоров не было"
stroki = [f"КАРТА СЛАБЫХ МЕСТ — {karta['razgovorov']} разговоров"]
if karta["chisto"]:
stroki.append(" замечаний нет")
return "\n".join(stroki)
zagolovki = [("sistemnoe", "🔴 СИСТЕМНОЕ — повторяется в большинстве разговоров"),
("chastoe", "⚠️ ЧАСТОЕ — заметная доля, смотреть глазами"),
("shum", "· ШУМ — один-два раза, это случайность")]
for klyuch, zagolovok in zagolovki:
if not karta[klyuch]:
continue
stroki.append("")
stroki.append(zagolovok)
for s in karta[klyuch]:
stroki.append(f" [{s['tyazhest']}] признак {s['priznak']} ({s['spektr']}): "
f"{s['formulirovka']}")
stroki.append(f" {s['razgovorov']} из {s['iz']} разговоров"
f" ({s['dolya']:.0%}), срабатываний {s['vsego_srabotok']}")
for pr in s["primery"]:
stroki.append(f" пример: «{pr['citata'][:90]}»")
return "\n".join(stroki)
+14 -8
View File
@@ -1,13 +1,14 @@
import rubrika
def test_rubrika_soderzhit_36_priznakov():
assert len(rubrika.PRIZNAKI) == 36
def test_rubrika_soderzhit_42_priznaka():
assert len(rubrika.PRIZNAKI) == 42
def test_nomera_unikalny_i_bez_dyr():
nomera = [p.nomer for p in rubrika.PRIZNAKI]
assert nomera == list(range(1, 37))
nomera = sorted(p.nomer for p in rubrika.PRIZNAKI)
assert nomera == list(range(1, 43))
assert len(nomera) == len(set(nomera))
def test_polya_dopustimy():
@@ -23,7 +24,12 @@ def test_najti_po_nomeru():
assert rubrika.po_nomeru(28).tyazhest == "СИГНАЛ"
def test_spektr_V_sudit_prodavec():
# спектр V — новое, ради чего всё затевалось: продала ли она
for p in rubrika.spektra("V"):
assert p.kto == "продавец", p
def test_spektr_V_sudyat_prodavec_i_pribory():
"""Спектр V — ради чего всё затевалось: продала ли она.
Часть по НАШЕЙ методике меряется прибором (лесенка подачи, вопросы, цена вперёд),
часть требует понимания смысла — её судит роль «продавец».
"""
kto = {p.kto for p in rubrika.spektra("V")}
assert kto == {"продавец", "прибор"}, kto
assert len(rubrika.spektra("V")) == 11
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Накопитель: отделяет системное от случайного — иначе будем чинить шум."""
import svodka
def _k(id, *pary):
"""пары: (номер признака, тяжесть, цитата)"""
return {"id": id, "nahodki": [{"priznak": n, "tyazhest": t, "citata": c} for n, t, c in pary]}
DESYAT = (
[_k(f"з-{i}", (12, "СИГНАЛ", "доля речи 70%"), (42, "ТРЕВОГА", "доводов: 1")) for i in range(8)]
+ [_k("з-8", (12, "СИГНАЛ", "доля речи 68%"), (24, "ТРЕВОГА", "директолог"))]
+ [_k("з-9", (27, "ТРЕВОГА", "Александра, это не про рекламу"))]
)
def test_povtoryayushcheesya_eto_sistemnoe():
k = svodka.svesti(DESYAT)
nomera = [s["priznak"] for s in k["sistemnoe"]]
assert 12 in nomera and 42 in nomera
def test_odinochnoe_eto_shum():
"""Оба признака встретились по разу из десяти — это случайность, а не система."""
k = svodka.svesti(DESYAT)
assert sorted(s["priznak"] for s in k["shum"]) == [24, 27]
def test_srednee_eto_chastoe():
k = svodka.svesti([_k("а", (24, "ТРЕВОГА", "х")), _k("б", (24, "ТРЕВОГА", "х")),
_k("в"), _k("г"), _k("д")])
assert [s["priznak"] for s in k["chastoe"]] == [24]
def test_povtor_v_odnom_razgovore_ne_delaet_sistemnym():
"""Десять срабатываний в ОДНОМ разговоре — это один разговор, а не система."""
k = svodka.svesti([_k("а", *[(6, "ТРЕВОГА", "пауза")] * 10),
_k("б"), _k("в"), _k("г"), _k("д")])
s = k["shum"][0]
assert s["razgovorov"] == 1 and s["vsego_srabotok"] == 10
def test_primery_dословny_i_ogranicheny():
k = svodka.svesti(DESYAT)
for polka in ("sistemnoe", "chastoe", "shum"):
for s in k[polka]:
assert len(s["primery"]) <= svodka.PRIMEROV
for p in s["primery"]:
assert p["citata"]
def test_chistaya_pachka():
k = svodka.svesti([_k("а"), _k("б")])
assert k["chisto"] is True
assert "замечаний нет" in svodka.kratko(k)
def test_pustoy_spisok_ne_padaet():
assert svodka.svesti([])["razgovorov"] == 0