diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index 15af7b87..232f3d83 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -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-сессий. diff --git a/golos-test/sudya/dialog/pribory.py b/golos-test/sudya/dialog/pribory.py index 81720165..9d5ce24d 100644 --- a/golos-test/sudya/dialog/pribory.py +++ b/golos-test/sudya/dialog/pribory.py @@ -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 diff --git a/golos-test/sudya/dialog/rubrika.py b/golos-test/sudya/dialog/rubrika.py index 67d2451d..f77aca15 100644 --- a/golos-test/sudya/dialog/rubrika.py +++ b/golos-test/sudya/dialog/rubrika.py @@ -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", "прибор", "СТОП", "живой якорь получил НОРМА — иначе партия недействительна"), diff --git a/golos-test/sudya/dialog/sudya.py b/golos-test/sudya/dialog/sudya.py index bdd2073a..a5385fca 100644 --- a/golos-test/sudya/dialog/sudya.py +++ b/golos-test/sudya/dialog/sudya.py @@ -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) # Полная коллегия — на приёмке; в бою только по запросу (экономим деньги) diff --git a/golos-test/sudya/dialog/svodka.py b/golos-test/sudya/dialog/svodka.py new file mode 100644 index 00000000..ab958c9b --- /dev/null +++ b/golos-test/sudya/dialog/svodka.py @@ -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) diff --git a/golos-test/sudya/dialog/tests/test_rubrika.py b/golos-test/sudya/dialog/tests/test_rubrika.py index 8246c3b3..e825a6ab 100644 --- a/golos-test/sudya/dialog/tests/test_rubrika.py +++ b/golos-test/sudya/dialog/tests/test_rubrika.py @@ -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 diff --git a/golos-test/sudya/dialog/tests/test_svodka.py b/golos-test/sudya/dialog/tests/test_svodka.py new file mode 100644 index 00000000..ad8b8ebc --- /dev/null +++ b/golos-test/sudya/dialog/tests/test_svodka.py @@ -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