Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +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", [])
|
||||
Reference in New Issue
Block a user