@@ -1,353 +1,353 @@
""" Автономная ML-модель (наивный Байес по словам) для LeadRadar.
Хранилище — собственный DuckDB-файл (volume). Модель живёт в отдельном
контейнере и общается с основным приложением по HTTP:
* /learn, /learn-batch — обучение (всегда, независимо от настроек UI);
* /predict — предсказание (используется, только если mlEnabled);
* /status — готовность и статистика.
"""
from __future__ import annotations
import os
import re
import threading
import time
import duckdb
# Пороги уверенности: консервативные на старте, смягчаются по мере накопления
# опыта (см. _adaptive_margin) — ML постепенно берёт на себя больше работы.
MIN_TOTAL = 20 # суммарно примеров по всем классам, чтобы модель «включилась»
MIN_WINNER = 6 # минимум примеров у класса-победителя
MIN_WINNER_SPAM = 4
MIN_HITS = 2 # минимум различных терминов, встреченных у победителя
MARGIN = 0.9 # ln-отрыв от второго класса на старте
# Самооценка «справляется ли ML»: каждый реальный (пользовательский) обучающий
# сигнал сверяется с текущим предсказанием модели. В /status отдаётся окно
# последних решений — по нему UI подсказывает, что ИИ можно отключить.
EVAL_WINDOW = 50 # сколько последних решений показываем
EVAL_KEEP = 200 # сколько храним в БД модели
TOKEN_RE = re . compile ( r " [a-zа -яё0-9@+.#]+ " , re . I )
# Ссылки и markdown-ссылки не должны влиять ни на обучение, ни на предсказание:
# иначе модель учит мусор из URL (utm, source, campaign…) и режет по нему заявки.
_LINK_RE = re . compile ( r " https?://[^ \ s<> \" ' ]+|www \ .[^ \ s<> \" ' ]+| \ [[^ \ ]]* \ ] \ ([^) \ s]+ \ ) " )
DATA_PATH = os . getenv ( " ML_DATA " , " ./data/ml.duckdb " )
_lock = threading . RLock ( )
_con : duckdb . DuckDBPyConnection | None = None
def _adaptive_margin ( total : float ) - > float :
""" Отрыв от второго класса, требуемый для самостоятельного решения.
Чем больше примеров ML уже видела, тем ниже порог — модель набирается
опыта и постепенно заменяет ИИ на типовых сообщениях. На старте порог
консервативный (0.9), после ~400 примеров — 0.35.
"""
if total > = 400 :
return 0.35
if total > = 150 :
return 0.5
if total > = 60 :
return 0.7
return MARGIN
def _db ( ) - > duckdb . DuckDBPyConnection :
global _con
with _lock :
if _con is None :
os . makedirs ( os . path . dirname ( DATA_PATH ) or " . " , exist_ok = True )
_con = duckdb . connect ( DATA_PATH )
_con . execute (
" CREATE TABLE IF NOT EXISTS classes (label VARCHAR PRIMARY KEY, n DOUBLE NOT NULL DEFAULT 0, updated_at BIGINT) "
)
_con . execute (
" CREATE TABLE IF NOT EXISTS terms (label VARCHAR NOT NULL, term VARCHAR NOT NULL, count DOUBLE NOT NULL DEFAULT 0, "
" PRIMARY KEY (label, term)) "
)
_con . execute (
" CREATE TABLE IF NOT EXISTS eval_log (created_at BIGINT NOT NULL, "
" expected VARCHAR NOT NULL, predicted VARCHAR NOT NULL DEFAULT ' ' , correct BOOLEAN NOT NULL) "
)
return _con
def tokenize ( text : str ) - > list [ str ] :
text = _LINK_RE . sub ( " " , str ( text or " " ) )
words = [ w . lower ( ) for w in TOKEN_RE . findall ( text ) ]
out : list [ str ] = [ ]
for w in words :
if len ( w ) > = 3 :
out . append ( w )
if len ( w ) > = 6 :
out . append ( " ~ " + w [ : 4 ] )
return out
def totals ( ) - > dict [ str , float ] :
with _lock :
rows = _db ( ) . execute ( " SELECT label, n FROM classes WHERE n > 0 " ) . fetchall ( )
return { r [ 0 ] : float ( r [ 1 ] ) for r in rows }
def ready ( ) - > bool :
t = totals ( )
total = sum ( t . values ( ) )
if total < MIN_TOTAL :
return False
non_spam = { k : v for k , v in t . items ( ) if k != " spam " }
return t . get ( " spam " , 0 ) > = MIN_WINNER_SPAM and sum ( non_spam . values ( ) ) > = MIN_WINNER
def _upsert_one ( db , label : str , text : str , delta : float ) - > None :
""" Обновление одного обучающего примера (вызывается внутри транзакции).
Термины пишутся пакетно (executemany), а не по одному INSERT — на большой
модели построчная вставка занимает десятки секунд и блокирует /status и
/predict, из-за чего сервис «выглядит недоступным».
"""
label = str ( label )
if not text or not label :
return
now_ms = int ( time . time ( ) * 1000 )
db . execute (
" INSERT INTO classes(label, n, updated_at) VALUES (?, ?, ?) "
" ON CONFLICT(label) DO UPDATE SET n = classes.n + ?, updated_at = ? " ,
[ label , delta , now_ms , delta , now_ms ] ,
)
terms = tokenize ( text )
if terms :
db . executemany (
" INSERT INTO terms(label, term, count) VALUES (?, ?, ?) "
" ON CONFLICT(label, term) DO UPDATE SET count = terms.count + ? " ,
[ ( label , t , delta , delta ) for t in terms ] ,
)
if delta < 0 :
db . execute ( " DELETE FROM terms WHERE label = ? AND count <= 0 " , [ label ] )
db . execute ( " DELETE FROM classes WHERE n <= 0 " , [ ] )
def learn ( label : str , text : str , delta : float = 1.0 ) - > None :
""" Увеличить вес класса/терминов (delta>0) или «разучить» (delta<0). """
_maybe_eval ( label , text , delta )
with _lock :
db = _db ( )
db . execute ( " BEGIN " )
try :
_upsert_one ( db , label , text , delta )
db . execute ( " COMMIT " )
except Exception :
db . execute ( " ROLLBACK " )
raise
def learn_batch ( items : list [ dict ] ) - > int :
""" Пакетное обучение: одна транзакция + пакетные вставки терминов. """
if not items :
return 0
# самооценка по реальным действиям пользователя — до применения примеров
for it in items :
_maybe_eval (
str ( it . get ( " label " ) or " " ) ,
str ( it . get ( " text " ) or " " ) ,
float ( it . get ( " delta " , 1.0 ) ) ,
)
with _lock :
db = _db ( )
db . execute ( " BEGIN " )
try :
for it in items :
_upsert_one (
db ,
str ( it . get ( " label " ) or " " ) ,
str ( it . get ( " text " ) or " " ) ,
float ( it . get ( " delta " , 1.0 ) ) ,
)
db . execute ( " COMMIT " )
except Exception :
db . execute ( " ROLLBACK " )
raise
return len ( items )
# Классы типа заявки (ML учит их по ИИ-решениям/действиям, чтобы со временем
# сам определять «занятость vs разовая сделка» без вызова ИИ)
TYPE_HIRE = " t:hire "
TYPE_ORDER = " t:order "
# минимум примеров типа, чтобы ML начал выдавать тип
MIN_TYPE_WINNER = 4
def predict ( text : str ) - > dict :
tokens = tokenize ( text )
t = totals ( )
if not t or not tokens :
return { " take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : ready ( ) , " type " : None }
# Модель «включается» только с опытом: пока примеров мало (ready=False),
# она ничего не решает и не может ошибочно удалить заявку как спам.
if not ready ( ) :
return { " take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : False , " type " : None }
total = sum ( t . values ( ) )
type_classes = { k : v for k , v in t . items ( ) if k in ( TYPE_HIRE , TYPE_ORDER ) }
with _lock :
db = _db ( )
scores : dict [ str , float ] = { }
hits : dict [ str , int ] = { }
for label , n in t . items ( ) :
rows = db . execute ( " SELECT term, count FROM terms WHERE label = ? AND count > 0 " , [ label ] ) . fetchall ( )
weights = { r [ 0 ] : float ( r [ 1 ] ) for r in rows }
score = 0.0
hit = 0
for term in set ( tokens ) :
w = weights . get ( term )
if w :
score + = 1.0 if w < 1 else ( 1.0 + ( w - 1.0 ) / ( w + 1.0 ) )
hit + = 1
if hit :
scores [ label ] = score
hits [ label ] = hit
margin = _adaptive_margin ( total )
prior = { label : n / total for label , n in t . items ( ) }
# ── тип заявки: только t:hire / t:order ───────────────────────────────
type_decision = None
if len ( type_classes ) > = 2 :
ranked_t = sorted (
( ( k , scores . get ( k , 0.0 ) ) for k in type_classes ) ,
key = lambda kv : - kv [ 1 ] ,
)
bt_label , bt_score = ranked_t [ 0 ]
st = ranked_t [ 1 ] if len ( ranked_t ) > 1 else None
bt_total = bt_score + 3.0 * prior . get ( bt_label , 0.0 )
st_total = ( st [ 1 ] + 3.0 * prior . get ( st [ 0 ] , 0.0 ) ) if st else 0.0
if (
bt_score > 0
and t . get ( bt_label , 0 ) > = MIN_TYPE_WINNER
and ( bt_total - st_total ) > = margin
) :
type_decision = {
" take " : True ,
" label " : " hire " if bt_label == TYPE_HIRE else " order " ,
" value " : bt_label ,
" margin " : round ( margin , 2 ) ,
}
# ── колонка/спам: без t:* классов ─────────────────────────────────────
regular = { k : v for k , v in t . items ( ) if not k . startswith ( " t: " ) }
if not regular or not scores :
return {
" take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : ready ( ) ,
" type " : type_decision ,
}
ranked = sorted ( ( ( k , scores [ k ] ) for k in regular if k in scores ) , key = lambda kv : - kv [ 1 ] )
if not ranked :
return {
" take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : ready ( ) ,
" type " : type_decision ,
}
best_label , best_score = ranked [ 0 ]
second = ranked [ 1 ] if len ( ranked ) > 1 else None
best_total = best_score + 3.0 * prior . get ( best_label , 0.0 )
second_total = ( second [ 1 ] + 3.0 * prior . get ( second [ 0 ] , 0.0 ) ) if second else 0.0
is_spam = best_label == " spam "
min_winner = MIN_WINNER_SPAM if is_spam else MIN_WINNER
take = (
t . get ( best_label , 0 ) > = min_winner
and hits [ best_label ] > = MIN_HITS
and ( best_total - second_total ) > = margin
)
# термины, которые модель «узнала» в тексте у класса-победителя:
# подсказка для структурирования карточки (стек/услуги/материалы) без ИИ
matched_terms : list [ str ] = [ ]
if take and best_label != " spam " :
with _lock :
db = _db ( )
rows = db . execute (
" SELECT term, count FROM terms WHERE label = ? AND count > 0 " , [ best_label ]
) . fetchall ( )
weights = { r [ 0 ] : float ( r [ 1 ] ) for r in rows }
seen = set ( )
for term in tokens :
if term . startswith ( " ~ " ) :
continue # хвостовой токен (~pyth) — не нужен как подсказка стека
if term in weights and term not in seen and term not in best_label :
seen . add ( term )
matched_terms . append ( term )
matched_terms = sorted ( seen , key = lambda x : - weights . get ( x , 0 ) ) [ : 8 ]
out_scores = { label : round ( s , 3 ) for label , s in sorted ( scores . items ( ) , key = lambda kv : - kv [ 1 ] ) [ : 5 ] }
return {
" take " : bool ( take ) ,
" label " : best_label if take else None ,
" scores " : out_scores ,
" hits " : hits [ best_label ] ,
" ready " : ready ( ) ,
" margin " : round ( margin , 2 ) ,
" terms " : matched_terms ,
" type " : type_decision ,
}
def _maybe_eval ( label : str , text : str , delta : float ) - > None :
""" Самооценка перед обучением на реальном действии пользователя.
delta=1.0 — пользовательское действие (перенос на доску, корзина, возврат,
ручная разметка): это «правильный ответ по карточке». Если модель уже
включена (ready) и уверенно взяла решение — сверяем его с действием:
совпало → в копилку верных, нет → в копилку ошибок. Гипотезы ИИ
(delta<1), типы t:* и «разучивание» (delta<0) не оцениваются.
"""
if delta != 1.0 or not ( text or " " ) . strip ( ) or str ( label or " " ) . startswith ( " t: " ) :
return
if not ready ( ) :
return
pr = predict ( text )
if not pr . get ( " take " ) or not pr . get ( " label " ) :
return # модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой
correct = bool ( pr [ " label " ] == label )
with _lock :
db = _db ( )
db . execute (
" INSERT INTO eval_log(created_at, expected, predicted, correct) VALUES (?, ?, ?, ?) " ,
[ int ( time . time ( ) * 1000 ) , str ( label ) , str ( pr [ " label " ] ) , correct ] ,
)
db . execute (
f " DELETE FROM eval_log WHERE created_at < "
f " (SELECT created_at FROM eval_log ORDER BY created_at DESC LIMIT 1 OFFSET { EVAL_KEEP } ) "
)
def status ( ) - > dict :
t = totals ( )
# окно самооценки: последние решения модели, подтверждённые действиями
# пользователя (перенос на доску, корзина, возврат, ручная разметка)
ev = { " count " : 0 , " correct " : 0 , " accuracy " : 0.0 }
with _lock :
rows = _db ( ) . execute (
" SELECT count(*) AS c, coalesce(sum(CASE WHEN correct THEN 1 ELSE 0 END), 0) AS ok "
" FROM (SELECT correct FROM eval_log ORDER BY created_at DESC LIMIT ?) " ,
[ EVAL_WINDOW ] ,
) . fetchone ( )
if rows and rows [ 0 ] :
ev [ " count " ] = int ( rows [ 0 ] )
ev [ " correct " ] = int ( rows [ 1 ] )
ev [ " accuracy " ] = round ( ev [ " correct " ] / ev [ " count " ] , 3 ) if ev [ " count " ] else 0.0
return {
" ready " : ready ( ) ,
" classes " : { label : round ( n , 2 ) for label , n in sorted ( t . items ( ) , key = lambda kv : - kv [ 1 ] ) } ,
" learned " : int ( sum ( t . values ( ) ) ) ,
" eval " : ev ,
}
def reset ( ) - > None :
with _lock :
db = _db ( )
db . execute ( " DELETE FROM terms " , [ ] )
db . execute ( " DELETE FROM classes " , [ ] )
db . execute ( " DELETE FROM eval_log " , [ ] )
""" Автономная ML-модель (наивный Байес по словам) для LeadRadar.
Хранилище — собственный DuckDB-файл (volume). Модель живёт в отдельном
контейнере и общается с основным приложением по HTTP:
* /learn, /learn-batch — обучение (всегда, независимо от настроек UI);
* /predict — предсказание (используется, только если mlEnabled);
* /status — готовность и статистика.
"""
from __future__ import annotations
import os
import re
import threading
import time
import duckdb
# Пороги уверенности: консервативные на старте, смягчаются по мере накопления
# опыта (см. _adaptive_margin) — ML постепенно берёт на себя больше работы.
MIN_TOTAL = 20 # суммарно примеров по всем классам, чтобы модель «включилась»
MIN_WINNER = 6 # минимум примеров у класса-победителя
MIN_WINNER_SPAM = 4
MIN_HITS = 2 # минимум различных терминов, встреченных у победителя
MARGIN = 0.9 # ln-отрыв от второго класса на старте
# Самооценка «справляется ли ML»: каждый реальный (пользовательский) обучающий
# сигнал сверяется с текущим предсказанием модели. В /status отдаётся окно
# последних решений — по нему UI подсказывает, что ИИ можно отключить.
EVAL_WINDOW = 50 # сколько последних решений показываем
EVAL_KEEP = 200 # сколько храним в БД модели
TOKEN_RE = re . compile ( r " [a-zа -яё0-9@+.#]+ " , re . I )
# Ссылки и markdown-ссылки не должны влиять ни на обучение, ни на предсказание:
# иначе модель учит мусор из URL (utm, source, campaign…) и режет по нему заявки.
_LINK_RE = re . compile ( r " https?://[^ \ s<> \" ' ]+|www \ .[^ \ s<> \" ' ]+| \ [[^ \ ]]* \ ] \ ([^) \ s]+ \ ) " )
DATA_PATH = os . getenv ( " ML_DATA " , " ./data/ml.duckdb " )
_lock = threading . RLock ( )
_con : duckdb . DuckDBPyConnection | None = None
def _adaptive_margin ( total : float ) - > float :
""" Отрыв от второго класса, требуемый для самостоятельного решения.
Чем больше примеров ML уже видела, тем ниже порог — модель набирается
опыта и постепенно заменяет ИИ на типовых сообщениях. На старте порог
консервативный (0.9), после ~400 примеров — 0.35.
"""
if total > = 400 :
return 0.35
if total > = 150 :
return 0.5
if total > = 60 :
return 0.7
return MARGIN
def _db ( ) - > duckdb . DuckDBPyConnection :
global _con
with _lock :
if _con is None :
os . makedirs ( os . path . dirname ( DATA_PATH ) or " . " , exist_ok = True )
_con = duckdb . connect ( DATA_PATH )
_con . execute (
" CREATE TABLE IF NOT EXISTS classes (label VARCHAR PRIMARY KEY, n DOUBLE NOT NULL DEFAULT 0, updated_at BIGINT) "
)
_con . execute (
" CREATE TABLE IF NOT EXISTS terms (label VARCHAR NOT NULL, term VARCHAR NOT NULL, count DOUBLE NOT NULL DEFAULT 0, "
" PRIMARY KEY (label, term)) "
)
_con . execute (
" CREATE TABLE IF NOT EXISTS eval_log (created_at BIGINT NOT NULL, "
" expected VARCHAR NOT NULL, predicted VARCHAR NOT NULL DEFAULT ' ' , correct BOOLEAN NOT NULL) "
)
return _con
def tokenize ( text : str ) - > list [ str ] :
text = _LINK_RE . sub ( " " , str ( text or " " ) )
words = [ w . lower ( ) for w in TOKEN_RE . findall ( text ) ]
out : list [ str ] = [ ]
for w in words :
if len ( w ) > = 3 :
out . append ( w )
if len ( w ) > = 6 :
out . append ( " ~ " + w [ : 4 ] )
return out
def totals ( ) - > dict [ str , float ] :
with _lock :
rows = _db ( ) . execute ( " SELECT label, n FROM classes WHERE n > 0 " ) . fetchall ( )
return { r [ 0 ] : float ( r [ 1 ] ) for r in rows }
def ready ( ) - > bool :
t = totals ( )
total = sum ( t . values ( ) )
if total < MIN_TOTAL :
return False
non_spam = { k : v for k , v in t . items ( ) if k != " spam " }
return t . get ( " spam " , 0 ) > = MIN_WINNER_SPAM and sum ( non_spam . values ( ) ) > = MIN_WINNER
def _upsert_one ( db , label : str , text : str , delta : float ) - > None :
""" Обновление одного обучающего примера (вызывается внутри транзакции).
Термины пишутся пакетно (executemany), а не по одному INSERT — на большой
модели построчная вставка занимает десятки секунд и блокирует /status и
/predict, из-за чего сервис «выглядит недоступным».
"""
label = str ( label )
if not text or not label :
return
now_ms = int ( time . time ( ) * 1000 )
db . execute (
" INSERT INTO classes(label, n, updated_at) VALUES (?, ?, ?) "
" ON CONFLICT(label) DO UPDATE SET n = classes.n + ?, updated_at = ? " ,
[ label , delta , now_ms , delta , now_ms ] ,
)
terms = tokenize ( text )
if terms :
db . executemany (
" INSERT INTO terms(label, term, count) VALUES (?, ?, ?) "
" ON CONFLICT(label, term) DO UPDATE SET count = terms.count + ? " ,
[ ( label , t , delta , delta ) for t in terms ] ,
)
if delta < 0 :
db . execute ( " DELETE FROM terms WHERE label = ? AND count <= 0 " , [ label ] )
db . execute ( " DELETE FROM classes WHERE n <= 0 " , [ ] )
def learn ( label : str , text : str , delta : float = 1.0 ) - > None :
""" Увеличить вес класса/терминов (delta>0) или «разучить» (delta<0). """
_maybe_eval ( label , text , delta )
with _lock :
db = _db ( )
db . execute ( " BEGIN " )
try :
_upsert_one ( db , label , text , delta )
db . execute ( " COMMIT " )
except Exception :
db . execute ( " ROLLBACK " )
raise
def learn_batch ( items : list [ dict ] ) - > int :
""" Пакетное обучение: одна транзакция + пакетные вставки терминов. """
if not items :
return 0
# самооценка по реальным действиям пользователя — до применения примеров
for it in items :
_maybe_eval (
str ( it . get ( " label " ) or " " ) ,
str ( it . get ( " text " ) or " " ) ,
float ( it . get ( " delta " , 1.0 ) ) ,
)
with _lock :
db = _db ( )
db . execute ( " BEGIN " )
try :
for it in items :
_upsert_one (
db ,
str ( it . get ( " label " ) or " " ) ,
str ( it . get ( " text " ) or " " ) ,
float ( it . get ( " delta " , 1.0 ) ) ,
)
db . execute ( " COMMIT " )
except Exception :
db . execute ( " ROLLBACK " )
raise
return len ( items )
# Классы типа заявки (ML учит их по ИИ-решениям/действиям, чтобы со временем
# сам определять «занятость vs разовая сделка» без вызова ИИ)
TYPE_HIRE = " t:hire "
TYPE_ORDER = " t:order "
# минимум примеров типа, чтобы ML начал выдавать тип
MIN_TYPE_WINNER = 4
def predict ( text : str ) - > dict :
tokens = tokenize ( text )
t = totals ( )
if not t or not tokens :
return { " take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : ready ( ) , " type " : None }
# Модель «включается» только с опытом: пока примеров мало (ready=False),
# она ничего не решает и не может ошибочно удалить заявку как спам.
if not ready ( ) :
return { " take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : False , " type " : None }
total = sum ( t . values ( ) )
type_classes = { k : v for k , v in t . items ( ) if k in ( TYPE_HIRE , TYPE_ORDER ) }
with _lock :
db = _db ( )
scores : dict [ str , float ] = { }
hits : dict [ str , int ] = { }
for label , n in t . items ( ) :
rows = db . execute ( " SELECT term, count FROM terms WHERE label = ? AND count > 0 " , [ label ] ) . fetchall ( )
weights = { r [ 0 ] : float ( r [ 1 ] ) for r in rows }
score = 0.0
hit = 0
for term in set ( tokens ) :
w = weights . get ( term )
if w :
score + = 1.0 if w < 1 else ( 1.0 + ( w - 1.0 ) / ( w + 1.0 ) )
hit + = 1
if hit :
scores [ label ] = score
hits [ label ] = hit
margin = _adaptive_margin ( total )
prior = { label : n / total for label , n in t . items ( ) }
# ── тип заявки: только t:hire / t:order ───────────────────────────────
type_decision = None
if len ( type_classes ) > = 2 :
ranked_t = sorted (
( ( k , scores . get ( k , 0.0 ) ) for k in type_classes ) ,
key = lambda kv : - kv [ 1 ] ,
)
bt_label , bt_score = ranked_t [ 0 ]
st = ranked_t [ 1 ] if len ( ranked_t ) > 1 else None
bt_total = bt_score + 3.0 * prior . get ( bt_label , 0.0 )
st_total = ( st [ 1 ] + 3.0 * prior . get ( st [ 0 ] , 0.0 ) ) if st else 0.0
if (
bt_score > 0
and t . get ( bt_label , 0 ) > = MIN_TYPE_WINNER
and ( bt_total - st_total ) > = margin
) :
type_decision = {
" take " : True ,
" label " : " hire " if bt_label == TYPE_HIRE else " order " ,
" value " : bt_label ,
" margin " : round ( margin , 2 ) ,
}
# ── колонка/спам: без t:* классов ─────────────────────────────────────
regular = { k : v for k , v in t . items ( ) if not k . startswith ( " t: " ) }
if not regular or not scores :
return {
" take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : ready ( ) ,
" type " : type_decision ,
}
ranked = sorted ( ( ( k , scores [ k ] ) for k in regular if k in scores ) , key = lambda kv : - kv [ 1 ] )
if not ranked :
return {
" take " : False , " label " : None , " scores " : { } , " hits " : 0 , " ready " : ready ( ) ,
" type " : type_decision ,
}
best_label , best_score = ranked [ 0 ]
second = ranked [ 1 ] if len ( ranked ) > 1 else None
best_total = best_score + 3.0 * prior . get ( best_label , 0.0 )
second_total = ( second [ 1 ] + 3.0 * prior . get ( second [ 0 ] , 0.0 ) ) if second else 0.0
is_spam = best_label == " spam "
min_winner = MIN_WINNER_SPAM if is_spam else MIN_WINNER
take = (
t . get ( best_label , 0 ) > = min_winner
and hits [ best_label ] > = MIN_HITS
and ( best_total - second_total ) > = margin
)
# термины, которые модель «узнала» в тексте у класса-победителя:
# подсказка для структурирования карточки (стек/услуги/материалы) без ИИ
matched_terms : list [ str ] = [ ]
if take and best_label != " spam " :
with _lock :
db = _db ( )
rows = db . execute (
" SELECT term, count FROM terms WHERE label = ? AND count > 0 " , [ best_label ]
) . fetchall ( )
weights = { r [ 0 ] : float ( r [ 1 ] ) for r in rows }
seen = set ( )
for term in tokens :
if term . startswith ( " ~ " ) :
continue # хвостовой токен (~pyth) — не нужен как подсказка стека
if term in weights and term not in seen and term not in best_label :
seen . add ( term )
matched_terms . append ( term )
matched_terms = sorted ( seen , key = lambda x : - weights . get ( x , 0 ) ) [ : 8 ]
out_scores = { label : round ( s , 3 ) for label , s in sorted ( scores . items ( ) , key = lambda kv : - kv [ 1 ] ) [ : 5 ] }
return {
" take " : bool ( take ) ,
" label " : best_label if take else None ,
" scores " : out_scores ,
" hits " : hits [ best_label ] ,
" ready " : ready ( ) ,
" margin " : round ( margin , 2 ) ,
" terms " : matched_terms ,
" type " : type_decision ,
}
def _maybe_eval ( label : str , text : str , delta : float ) - > None :
""" Самооценка перед обучением на реальном действии пользователя.
delta=1.0 — пользовательское действие (перенос на доску, корзина, возврат,
ручная разметка): это «правильный ответ по карточке». Если модель уже
включена (ready) и уверенно взяла решение — сверяем его с действием:
совпало → в копилку верных, нет → в копилку ошибок. Гипотезы ИИ
(delta<1), типы t:* и «разучивание» (delta<0) не оцениваются.
"""
if delta != 1.0 or not ( text or " " ) . strip ( ) or str ( label or " " ) . startswith ( " t: " ) :
return
if not ready ( ) :
return
pr = predict ( text )
if not pr . get ( " take " ) or not pr . get ( " label " ) :
return # модель не уверена — такое сообщение ушло бы ИИ, не считаем ошибкой
correct = bool ( pr [ " label " ] == label )
with _lock :
db = _db ( )
db . execute (
" INSERT INTO eval_log(created_at, expected, predicted, correct) VALUES (?, ?, ?, ?) " ,
[ int ( time . time ( ) * 1000 ) , str ( label ) , str ( pr [ " label " ] ) , correct ] ,
)
db . execute (
f " DELETE FROM eval_log WHERE created_at < "
f " (SELECT created_at FROM eval_log ORDER BY created_at DESC LIMIT 1 OFFSET { EVAL_KEEP } ) "
)
def status ( ) - > dict :
t = totals ( )
# окно самооценки: последние решения модели, подтверждённые действиями
# пользователя (перенос на доску, корзина, возврат, ручная разметка)
ev = { " count " : 0 , " correct " : 0 , " accuracy " : 0.0 }
with _lock :
rows = _db ( ) . execute (
" SELECT count(*) AS c, coalesce(sum(CASE WHEN correct THEN 1 ELSE 0 END), 0) AS ok "
" FROM (SELECT correct FROM eval_log ORDER BY created_at DESC LIMIT ?) " ,
[ EVAL_WINDOW ] ,
) . fetchone ( )
if rows and rows [ 0 ] :
ev [ " count " ] = int ( rows [ 0 ] )
ev [ " correct " ] = int ( rows [ 1 ] )
ev [ " accuracy " ] = round ( ev [ " correct " ] / ev [ " count " ] , 3 ) if ev [ " count " ] else 0.0
return {
" ready " : ready ( ) ,
" classes " : { label : round ( n , 2 ) for label , n in sorted ( t . items ( ) , key = lambda kv : - kv [ 1 ] ) } ,
" learned " : int ( sum ( t . values ( ) ) ) ,
" eval " : ev ,
}
def reset ( ) - > None :
with _lock :
db = _db ( )
db . execute ( " DELETE FROM terms " , [ ] )
db . execute ( " DELETE FROM classes " , [ ] )
db . execute ( " DELETE FROM eval_log " , [ ] )