Files
Deal/archive/leadradar-legacy/mlservice/model.py
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

354 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Автономная 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", [])