Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
391 lines
19 KiB
Python
391 lines
19 KiB
Python
"""Детерминированные правила колонок: направление, стек, слова, грейд, бюджет.
|
||
|
||
Колонка — это набор опциональных фильтров, задаёт пользователь (или ИИ при
|
||
предложении). Если текст входящего сообщения соответствует правилам — карточка
|
||
уходит в эту колонку сразу, без ML/ИИ (ML/ИИ подключаются только когда правила
|
||
не сработали). Набор фильтров может меняться: пустая группа не участвует.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
|
||
from ..db import store
|
||
|
||
_CUR_SYMBOLS = {"$": "USD", "€": "EUR", "₽": "RUB", "₮": "USDT", "£": "GBP", "¥": "CNY"}
|
||
_CUR_WORDS = {"usd": "USD", "eur": "EUR", "rub": "RUB", "usdt": "USDT", "gbp": "GBP", "cny": "CNY", "руб": "RUB", "долл": "USD", "бакс": "USD"}
|
||
|
||
# Грейд/уровень: тег из правил расширяется синонимами, чтобы «middle» находил
|
||
# и «mid», и «мидл», а «сеньор» находил senior и т.п.
|
||
_GRADE_ALIASES = {
|
||
"junior": ["junior", "джун", "джуниор"],
|
||
"middle": ["middle", "mid", "мидл"],
|
||
"senior": ["senior", "сеньор", "сеньйор"],
|
||
"lead": ["lead", "тимлид", "тиэмлид", "team lead", "teamlead", "tech lead", "техлид"],
|
||
"architect": ["architect", "архитектор"],
|
||
"intern": ["intern", "стажёр", "стажер", "trainee"],
|
||
}
|
||
|
||
|
||
def _grade_terms(tags: list[str]) -> list[str]:
|
||
out: list[str] = []
|
||
for t in tags:
|
||
key = str(t).strip().lower()
|
||
if not key:
|
||
continue
|
||
if key in _GRADE_ALIASES:
|
||
out.extend(_GRADE_ALIASES[key])
|
||
else:
|
||
out.append(key)
|
||
return out
|
||
|
||
|
||
# Перед матчингом правил вырезаем ссылки и markdown-ссылки: иначе фильтр ловит
|
||
# слова из трекерных хвостов/служебных строк URL (например, «desktop» в
|
||
# utm_medium=member_desktop) и в колонку попадает мусор, не имеющий отношения
|
||
# к содержанию сообщения.
|
||
_MD_URL_RE = re.compile(r"\[[^\]]*\]\([^)\s]+\)")
|
||
_RAW_URL_RE = re.compile(r"https?://[^\s<>\"']+|www\.[^\s<>\"']+")
|
||
|
||
|
||
def content_text(text: str) -> str:
|
||
s = str(text or "")
|
||
s = _MD_URL_RE.sub(" ", s)
|
||
return _RAW_URL_RE.sub(" ", s)
|
||
|
||
# ищем: 1) "от 1 200 до 1 500 $", 2) "1 200–1 500 $ / 1 200$", 3) "$1 200–1 500"
|
||
# суффикс «к/К» разрешён прямо в числе: «2к», «1.5к$» (множитель в _norm_amount)
|
||
_AMT = r"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?"
|
||
_RANGE = rf"({_AMT})\s*(?:[-–—]\s*({_AMT}))?"
|
||
|
||
|
||
def _norm_amount(raw: str) -> float | None:
|
||
s = raw.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
|
||
mult = 1.0
|
||
# «2к»/«2К»/«1.5к» — тысячи; суффикс убираем до float (иначе парс падает)
|
||
if s and s[-1].lower() in ("k", "к"):
|
||
mult = 1000.0
|
||
s = s[:-1]
|
||
try:
|
||
v = float(s) * mult
|
||
except ValueError:
|
||
return None
|
||
return v
|
||
|
||
|
||
def _cur_from_tail(text: str, m_start: int) -> str | None:
|
||
tail = text[m_start : m_start + 12].lower().strip()
|
||
for sym, code in _CUR_SYMBOLS.items():
|
||
if tail.startswith(sym):
|
||
return code
|
||
# слово-валюта сразу после числа (с пробелом)
|
||
for word, code in _CUR_WORDS.items():
|
||
if tail.startswith(word):
|
||
return code
|
||
# валюта перед числом ($/€/₽), например "$1 200"
|
||
head = text[max(0, m_start - 3) : m_start].strip()
|
||
for sym, code in _CUR_SYMBOLS.items():
|
||
if head.endswith(sym):
|
||
return code
|
||
return None
|
||
|
||
|
||
def extract_amounts(text: str) -> list[dict]:
|
||
"""Парсер сумм с сохранением смысла:
|
||
- диапазон «от A до B» / «A–B» → {'from': A, 'to': B, 'cur': ...};
|
||
- «до B» (только верхняя граница) → {'from': None, 'to': B, 'cur': ...};
|
||
- одна сумма / «от A» без верхней границы → {'from': A, 'to': A, 'cur': ...}.
|
||
Валюту ищем сразу после суммы (или перед ней для «$1 200»); суммы без валюты игнорируются.
|
||
"""
|
||
out: list[dict] = []
|
||
t = text or ""
|
||
if not t.strip():
|
||
return out
|
||
occupied: list[tuple[int, int]] = []
|
||
|
||
def _free(s: int, e: int) -> bool:
|
||
return not any(s < oe and e > os for os, oe in occupied)
|
||
|
||
def _add(a, b, cur: str | None, s: int, e: int) -> None:
|
||
if cur and (a is not None or b is not None) and _free(s, e):
|
||
out.append({"from": a, "to": b, "cur": cur})
|
||
occupied.append((s, e))
|
||
|
||
# 1) словесный диапазон «от A до B» (+ валюта после B)
|
||
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")\s+до\s+(" + _AMT + r")", t):
|
||
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
|
||
cur = _cur_from_tail(t, m.end(2))
|
||
if a is not None and b is not None and cur:
|
||
_add(a, b, cur, m.start(1), m.end(2))
|
||
# 2) «до B» (без пары «от … до») — верхняя граница
|
||
for m in re.finditer(r"(?i)\bдо\s+(" + _AMT + r")", t):
|
||
b = _norm_amount(m.group(1))
|
||
cur = _cur_from_tail(t, m.end(1))
|
||
if b is not None and cur:
|
||
_add(None, b, cur, m.start(1), m.end(1))
|
||
# 3) «от A» без верхней границы — считаем одной суммой
|
||
for m in re.finditer(r"(?i)\bот\s+(" + _AMT + r")", t):
|
||
a = _norm_amount(m.group(1))
|
||
cur = _cur_from_tail(t, m.end(1))
|
||
if a is not None and cur:
|
||
_add(a, a, cur, m.start(1), m.end(1))
|
||
# 4) числовые диапазоны «A–B» / «A - B»
|
||
for m in re.finditer(r"(?i)(" + _AMT + r")\s*(?:[-–—]|\s+до\s+)\s*(" + _AMT + r")", t):
|
||
a, b = _norm_amount(m.group(1)), _norm_amount(m.group(2))
|
||
cur = _cur_from_tail(t, m.end(2)) or _cur_from_tail(t, m.end(1))
|
||
if a is not None and b is not None and cur:
|
||
_add(a, b, cur, m.start(1), m.end(2))
|
||
# 5) одиночные суммы с валютой (не вошедшие в конструкции выше)
|
||
for m in re.finditer(_AMT, t):
|
||
a = _norm_amount(m.group(0))
|
||
cur = _cur_from_tail(t, m.end())
|
||
if a is not None and cur:
|
||
_add(a, a, cur, m.start(), m.end())
|
||
return out
|
||
|
||
|
||
def _amount_in_range(amounts: list[dict], budget: dict) -> bool:
|
||
from ..services.rates import convert_amount
|
||
|
||
try:
|
||
lo = float(budget.get("from")) if budget.get("from") is not None else None
|
||
hi = float(budget.get("to")) if budget.get("to") is not None else None
|
||
except (TypeError, ValueError):
|
||
return False
|
||
if lo is None and hi is None:
|
||
return True
|
||
target_cur = str(budget.get("cur") or "USD").upper()
|
||
for amt in amounts:
|
||
raw_val = amt["from"] if amt["from"] is not None else amt.get("to")
|
||
if raw_val is None:
|
||
continue
|
||
try:
|
||
val = raw_val if amt["cur"] == target_cur else convert_amount(raw_val, amt["cur"], target_cur)
|
||
except Exception: # noqa: BLE001
|
||
val = None
|
||
if val is None:
|
||
continue
|
||
if lo is not None and val < lo:
|
||
continue
|
||
if hi is not None and val > hi:
|
||
continue
|
||
return True
|
||
return False
|
||
|
||
|
||
def match_text(rules: dict, text: str) -> bool:
|
||
"""Соответствует ли текст правилам колонки. Возвращает bool.
|
||
|
||
Колонка — это набор опциональных фильтров: направление, стек, ключевые
|
||
слова, грейд/уровень, бюджет. Пустая группа не участвует; режим «все» —
|
||
должны совпасть все включённые группы, «любое» — хотя бы одна.
|
||
"""
|
||
rules = rules or {}
|
||
lower = content_text(text).lower()
|
||
direction = [str(x).strip().lower() for x in (rules.get("direction") or []) if str(x).strip()]
|
||
kw = [str(x).strip().lower() for x in (rules.get("keywords") or []) if str(x).strip()]
|
||
stack = [str(x).strip().lower() for x in (rules.get("stack") or []) if str(x).strip()]
|
||
grade = [str(x).strip().lower() for x in (rules.get("grade") or []) if str(x).strip()]
|
||
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
|
||
enabled = [bool(kw), bool(stack), bool(direction), bool(grade), bool(budget)]
|
||
|
||
if not any(enabled):
|
||
return False
|
||
grade_terms = _grade_terms(grade)
|
||
groups = {
|
||
"keywords": any(k in lower for k in kw) if kw else True,
|
||
"stack": any(s in lower for s in stack) if stack else True,
|
||
"direction": any(d in lower for d in direction) if direction else True,
|
||
"grade": any(g in lower for g in grade_terms) if grade else True,
|
||
"budget": (_amount_in_range(extract_amounts(text), budget) if budget else True),
|
||
}
|
||
mode = str(rules.get("mode") or "all").lower()
|
||
if mode == "any":
|
||
# «любое»: совпасть должна хотя бы одна включённая (непустая) группа.
|
||
# Пустые группы равны True и не должны участвовать — иначе колонка
|
||
# с одним фильтром (например «стек: WPF») ловит вообще всё.
|
||
return any(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
|
||
# all: каждая включённая группа обязана совпасть
|
||
return all(groups[k] for k, on in zip(groups, enabled) if on and k in groups)
|
||
|
||
|
||
def score_text(rules: dict, text: str) -> int:
|
||
"""Число совпавших фильтров (для выбора лучшей ИИ-колонки)."""
|
||
if not text:
|
||
return 0
|
||
lower = content_text(text).lower()
|
||
score = 0
|
||
for group in ("direction", "keywords", "stack", "grade"):
|
||
if group == "grade":
|
||
for g in _grade_terms(rules.get(group) or []):
|
||
if g in lower:
|
||
score += 1
|
||
else:
|
||
for w in (rules.get(group) or []):
|
||
if str(w).lower() in lower:
|
||
score += 1
|
||
return score
|
||
|
||
|
||
def excluded_terms(rules: dict | None, text: str) -> list[str]:
|
||
"""Какие слова-исключения колонки есть в тексте.
|
||
|
||
Исключения — veto колонки: если в содержании сообщения (без ссылок и
|
||
служебных хвостов) встречается любое из них, карточка в колонку не
|
||
попадает, даже если все положительные условия совпали.
|
||
"""
|
||
rules = rules or {}
|
||
lower = content_text(text).lower()
|
||
out: list[str] = []
|
||
for term in rules.get("exclude") or []:
|
||
t = str(term).strip()
|
||
if t and t.lower() in lower:
|
||
out.append(t)
|
||
return out
|
||
|
||
|
||
def is_excluded(rules: dict | None, text: str) -> bool:
|
||
return bool(excluded_terms(rules, text))
|
||
|
||
|
||
def board_accepts(board_id: str, text: str) -> bool:
|
||
"""Пропускает ли колонка этот текст.
|
||
|
||
Колонка с активными правилами принимает только текст, прошедший её правила
|
||
(детерминированно); колонка без правил принимает любой текст — её наполняют
|
||
ИИ/ML/пользователь. Нужно как страховка: ИИ или ML не должны класть карточку
|
||
в «отфильтрованную» колонку, если текст под правила не подходит. Слова-
|
||
исключения работают как veto в любом случае.
|
||
"""
|
||
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
|
||
if not row:
|
||
return False
|
||
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
|
||
if is_excluded(rules, text):
|
||
return False
|
||
if not has_active_rules(rules):
|
||
return True
|
||
return match_text(rules, text)
|
||
|
||
|
||
def hits(rules: dict, text: str) -> list[dict]:
|
||
"""Какие именно критерии фильтра совпали с текстом.
|
||
|
||
Возвращает список совпадений по группам фильтра колонки:
|
||
[{"label": "Стек", "term": "WPF"}, {"label": "Грейд", "term": "middle", "word": "mid"}, …].
|
||
Нужно, чтобы на карточке показывать «по каким критериям она попала в колонку»
|
||
(список совпавших условий фильтра). Ключевое слово ищется по всему тексту
|
||
сообщения — включая стек, требования и «будет плюсом», как и наоборот.
|
||
"""
|
||
rules = rules or {}
|
||
lower = content_text(text).lower()
|
||
out: list[dict] = []
|
||
for group, label in (("direction", "Направление"), ("keywords", "Слова"), ("stack", "Стек")):
|
||
for term in rules.get(group) or []:
|
||
t = str(term).strip()
|
||
if t and t.lower() in lower:
|
||
out.append({"label": label, "term": t})
|
||
for term in rules.get("grade") or []:
|
||
for alias in _grade_terms([term]):
|
||
if alias in lower:
|
||
out.append({"label": "Грейд/уровень", "term": str(term).strip(), "word": alias})
|
||
break
|
||
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
|
||
if budget and _amount_in_range(extract_amounts(text), budget):
|
||
out.append({"label": "Бюджет", "term": _budget_label(budget)})
|
||
return out
|
||
|
||
|
||
def _budget_label(budget: dict) -> str:
|
||
lo, hi = budget.get("from"), budget.get("to")
|
||
cur = str(budget.get("cur") or "").upper()
|
||
if lo is not None and hi is not None:
|
||
return f"от {lo:g} до {hi:g} {cur}".strip()
|
||
if hi is not None:
|
||
return f"до {hi:g} {cur}".strip()
|
||
if lo is not None:
|
||
return f"от {lo:g} {cur}".strip()
|
||
return "бюджет"
|
||
|
||
|
||
def hits_for_board(board_id: str, text: str) -> list[dict]:
|
||
"""Совпавшие критерии колонки с активными правилами; [] — правил нет."""
|
||
row = store.query_one("SELECT rules FROM boards WHERE id = ?", [board_id])
|
||
if not row:
|
||
return []
|
||
rules = json.loads(row["rules"] or "{}") if row["rules"] else {}
|
||
if not has_active_rules(rules):
|
||
return []
|
||
return hits(rules, text)
|
||
|
||
|
||
def has_active_rules(rules: dict | None) -> bool:
|
||
"""Есть ли в правилах хотя бы одна реально работающая группа фильтров.
|
||
|
||
Нужно для ML: колонка с активными правилами раскладывается только самими
|
||
правилами (детерминированно), ML её назначать не должен — иначе в колонку
|
||
попадает то, что под правила не подходит.
|
||
"""
|
||
rules = rules or {}
|
||
for key in ("direction", "keywords", "stack", "grade"):
|
||
if any(str(x).strip() for x in (rules.get(key) or [])):
|
||
return True
|
||
budget = rules.get("budget")
|
||
if isinstance(budget, dict) and (
|
||
budget.get("from") is not None or budget.get("to") is not None
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
def describe(rules: dict) -> str:
|
||
"""Человекочитаемое описание правил (для обоснования и промпта)."""
|
||
rules = rules or {}
|
||
parts = []
|
||
direction = rules.get("direction") or []
|
||
stack = rules.get("stack") or []
|
||
kw = rules.get("keywords") or []
|
||
grade = rules.get("grade") or []
|
||
if direction:
|
||
parts.append("направление: " + ", ".join(str(x) for x in direction[:6]))
|
||
if stack:
|
||
parts.append("стек: " + ", ".join(str(x) for x in stack[:8]))
|
||
if kw:
|
||
parts.append("слова: " + ", ".join(str(x) for x in kw[:8]))
|
||
if grade:
|
||
parts.append("грейд: " + ", ".join(str(x) for x in grade[:8]))
|
||
exc = rules.get("exclude") or []
|
||
if exc:
|
||
parts.append("исключено: " + ", ".join(str(x) for x in exc[:8]))
|
||
budget = rules.get("budget") if isinstance(rules.get("budget"), dict) else None
|
||
if budget and (budget.get("from") is not None or budget.get("to") is not None):
|
||
lo = budget.get("from") if budget.get("from") is not None else ""
|
||
hi = budget.get("to") if budget.get("to") is not None else ""
|
||
parts.append(f"бюджет: {lo}–{hi} {budget.get('cur') or ''}".replace('– ', '–').replace(' –', '–'))
|
||
if not parts:
|
||
return "без правил (решает ИИ/ML)"
|
||
mode = "все условия" if str(rules.get("mode") or "all").lower() != "any" else "любое из условий"
|
||
return mode + " · " + "; ".join(parts)
|
||
|
||
|
||
def route(text: str) -> dict | None:
|
||
"""Детерминированная маршрутизация по правилам активных колонок.
|
||
|
||
Возвращает колонку, если правила однозначно совпали (при нескольких —
|
||
ту, где больше совпадений). Предложения ИИ в маршрутизации не участвуют.
|
||
"""
|
||
rows = store.query(
|
||
"SELECT * FROM boards WHERE suggested = FALSE AND rules <> '{}' AND rules <> '' ORDER BY pos"
|
||
)
|
||
best: dict | None = None
|
||
best_score = 0
|
||
for r in rows:
|
||
rules = json.loads(r["rules"] or "{}")
|
||
if not match_text(rules, text):
|
||
continue
|
||
sc = score_text(rules, text) or 1
|
||
if sc > best_score:
|
||
best = {"id": r["id"], "name": r["name"], "color": r["color"], "rules": rules}
|
||
best_score = sc
|
||
return best
|