Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
354 lines
15 KiB
Python
354 lines
15 KiB
Python
"""Автономная 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", [])
|