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

238 lines
11 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.
"""Discovery: оценка контента — язык, темы, fit сообщений под задачу поиска.
Чистая логика оценки (без карточек, очередей и обучения):
* detect_lang_ru — доля кириллических букв среди всех букв выборки;
* group_by_topic — группировка выборки по topic_id (None -> "main")
для форумов: {topic_id, title, messages: [...]};
* evaluate_message — fit одного сообщения под задачу: каскад
короткое -> ML-спам -> ИИ -> эвристика;
* evaluate_sample — агрегат по выборке сообщений кандидата;
* passed — вердикт «источник подходит» (объём выборки + доля fit).
Каскад оценки сообщения (evaluate_message):
1) текст пустой/короче 10 символов -> False «слишком короткое»;
2) ML: ml_client.is_enabled() и прогноз take + label=='spam' -> False;
3) ИИ: если включён (aiEnabled) и доступен ключ/локальный провайдер — один
JSON-вызов ai_service.chat_json({fit, reason}); любая ошибка (нет ключа,
сеть, не-JSON) ловится и оценка продолжается эвристикой;
4) эвристика: любой ключ задачи входит в clean_short(text).casefold().
task — внешний dict в camelCase (конвенция discovery.Task 3): description,
keywords (list[str]), lang, threshold, sampleSize. Иные ключи игнорируются,
поэтому сюда можно передавать и полный view задачи из discovery.get_task.
"""
from __future__ import annotations
import logging
from ..db import store
from . import ai as ai_service
from . import ml_client
from .pipeline import clean_short
log = logging.getLogger("leadradar.discovery_eval")
# пороги доли кириллицы (задача ru-языка): >= hi -> True, <= lo -> False
_RU_RATIO_HI = 0.15
_RU_RATIO_LO = 0.03
# минимальная длина сообщения для содержательной оценки
_MIN_TEXT_LEN = 10
# ограничение текста, уходящего провайдеру (в одном сообщении больше не нужно)
_AI_TEXT_LIMIT = 4000
# потолок причины из ИИ (в UI не нужны простыни)
_AI_REASON_LIMIT = 200
# длина title темы (сниппет первого текста)
_TITLE_LIMIT = 60
# topic_id=None в выборке/группировке -> общая тема ("main")
_MAIN_TOPIC = "main"
# промпт ИИ-оценки (задача: относится ли сообщение к сфере/описанию)
_AI_PROMPT = (
"Оцени, относится ли сообщение к сфере/задаче. Описание: {description}. "
"Ключи: {keywords}. "
"Верни JSON {{\"fit\": 0|1, \"reason\": \"краткая причина\"}}."
)
def _is_cyrillic(ch: str) -> bool:
"""Кириллица ли символ (базовый блок U+0400–U+04FF, включает ё/ў и т.п.)."""
return 0x0400 <= ord(ch) <= 0x04FF
def detect_lang_ru(texts: list[str]) -> bool | None:
"""Доля кириллицы среди всех букв выборки: >=0.15 True, <=0.03 False, иначе None.
None означает «неопределённо» — воркер не отсекает кандидата, а помечает
язык как неподтверждённый. Пустая выборка без букв тоже даёт None.
"""
letters = 0
cyr = 0
for t in texts or []:
for ch in str(t or ""):
if ch.isalpha():
letters += 1
if _is_cyrillic(ch):
cyr += 1
if letters == 0:
return None
ratio = cyr / letters
if ratio >= _RU_RATIO_HI:
return True
if ratio <= _RU_RATIO_LO:
return False
return None
def _topic_title(messages: list[dict]) -> str:
"""Сниппет первого непустого текста темы (<=60 симв., whitespace схлопнут)."""
for m in messages:
text = str(m.get("text") or "").strip()
if text:
text = " ".join(text.split())
return text[:_TITLE_LIMIT]
return ""
def group_by_topic(messages: list[dict]) -> list[dict]:
"""Группирует сообщения по topic_id (None -> "main"), сортирует группы по
числу сообщений (убыв.), порядок сообщений внутри группы — входной.
Возвращает [{"topic_id", "title", "messages": [...]}], где title — сниппет
первого текста темы (первое непустое сообщение во входном порядке).
"""
groups: dict[str, list[dict]] = {}
order: list[str] = []
for m in messages or []:
tid = m.get("topic_id")
key = _MAIN_TOPIC if tid is None else tid
if key not in groups:
groups[key] = []
order.append(key)
groups[key].append(m)
out = [
{"topic_id": key, "title": _topic_title(groups[key]), "messages": groups[key]}
for key in order
]
out.sort(key=lambda g: len(g["messages"]), reverse=True)
return out
def _keywords(task: dict) -> list[str]:
kws = task.get("keywords") or []
if isinstance(kws, str):
kws = [kws]
return [str(k).strip() for k in kws if str(k).strip()]
def _ai_usable() -> bool:
"""ИИ-ветка доступна: полный выключатель включён и есть ключ/локальный сервер.
Локальные OpenAI-совместимые (Ollama/LM Studio) работают без ключа, поэтому
для них проверка ключа не требуется. Если статус провайдера прочитать нельзя
(нет БД/настроек) — считаем ИИ недоступным, чтобы не дёргать сеть впустую.
"""
if not store.get_setting("aiEnabled"):
return False
try:
status = ai_service.provider_status()
except Exception as exc: # noqa: BLE001 — отсутствие статуса = ИИ недоступен
log.debug("discovery eval: статус ИИ недоступен (%s) — эвристика", exc)
return False
return bool(status.get("local") or status.get("keySet"))
def _heuristic(task: dict, text: str) -> dict:
"""Эвристика: ключ задачи входит в очищенный текст (без учёта регистра)."""
hay = clean_short(text).casefold()
for kw in _keywords(task):
if kw and kw.casefold() in hay:
return {"fit": True, "reason": f'совпал ключ "{kw}"', "source": "heuristic"}
return {"fit": False, "reason": "нет совпадений с ключами", "source": "heuristic"}
def _ai_prompt(task: dict) -> str:
description = str(task.get("description") or "").strip()
return _AI_PROMPT.format(description=description, keywords=", ".join(_keywords(task)))
def _ai_fit(out: dict) -> bool:
"""fit из JSON-ответа ИИ: 1/true/«да»-подобные -> True, иначе False."""
v = out.get("fit")
if isinstance(v, str):
s = v.strip().casefold()
return bool(s) and s not in {"0", "false", "no", "нет", "null", "none"}
return bool(v)
def _ai_reason(out: dict) -> str:
reason = str(out.get("reason") or "").strip()
if not reason:
reason = "подходит" if _ai_fit(out) else "не подходит"
return reason[:_AI_REASON_LIMIT]
async def evaluate_message(task: dict, text: str) -> dict:
"""Оценка fit одного сообщения. Возвращает {"fit", "reason", "source"}."""
raw = str(text or "")
if len(raw.strip()) < _MIN_TEXT_LEN:
return {"fit": False, "reason": "слишком короткое", "source": "heuristic"}
# ML: уверенный спам отсекаем без обращения к ИИ (когда ML доступен)
if ml_client.is_enabled():
pred = await ml_client.predict(raw)
if pred.get("take") and pred.get("label") == "spam":
return {"fit": False, "reason": "ML: спам", "source": "ml"}
# ИИ: один JSON-вызов; любая ошибка провайдера -> шаг эвристики ниже
if _ai_usable():
try:
out = await ai_service.chat_json(_ai_prompt(task), f"Сообщение:\n{raw[:_AI_TEXT_LIMIT]}")
return {"fit": _ai_fit(out), "reason": _ai_reason(out), "source": "ai"}
except Exception as exc: # noqa: BLE001 — сбой ИИ не роняет оценку
log.debug("discovery eval: ИИ не ответил (%s) — эвристика", exc)
return _heuristic(task, raw)
async def evaluate_sample(task: dict, messages: list[dict]) -> dict:
"""Последовательная оценка всех сообщений выборки кандидата.
Возвращает {"fit_count", "total", "fit_ratio", "per_message": [...]} с
per_message [{"text", "fit", "reason", "topic_id"}] (topic_id None -> "main",
чтобы per_message стыковался с ключами group_by_topic).
"""
per_message = []
fit_count = 0
for m in messages or []:
text = str(m.get("text") or "")
res = await evaluate_message(task, text)
tid = m.get("topic_id")
per_message.append(
{
"text": text,
"fit": bool(res["fit"]),
"reason": str(res["reason"]),
"topic_id": _MAIN_TOPIC if tid is None else tid,
}
)
if res["fit"]:
fit_count += 1
total = len(per_message)
return {
"fit_count": fit_count,
"total": total,
"fit_ratio": (fit_count / total) if total else 0.0,
"per_message": per_message,
}
def passed(ev: dict, task: dict) -> bool:
"""Вердикт «источник подходит»: выборки >=3 сообщений и доля fit >= threshold (%).
Сообщения не обязаны быть «чистыми»: каналы часто разбавляют полезный
контент офтопом, поэтому достаточно доли, а не сплошного соответствия.
"""
total = int(ev.get("total") or 0)
ratio = float(ev.get("fit_ratio") or 0.0)
return total >= 3 and ratio * 100 >= int(task.get("threshold") or 0)