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