"""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)