"""AI-классификатор поверх выбранного провайдера. Провайдеров много (облако + локальные OpenAI-совместимые + Anthropic), активен один. Всё сведено к двум задачам: * filter_incoming(text) — этап 2 фильтра входящих (промпт aiFilterPrompt); * classify(text, boards) — разбор лида (промпт aiPrompt + обучающие примеры). Ответы моделей строго JSON; распознаём и обрезаем markdown-обёртки ```json. """ from __future__ import annotations import json import logging import re import httpx from .. import constants as C from ..crypto import decrypt_text from ..db import store from .rates import convert_amount log = logging.getLogger("leadradar.ai") def _cfg() -> tuple[str, dict]: provider_id = store.get_setting("aiProvider") or "deepseek" configs = store.get_setting("aiConfigs") or {} meta = next((p for p in C.AI_PROVIDERS if p["id"] == provider_id), None) or C.AI_PROVIDERS[0] raw = configs.get(provider_id, {}) cfg = dict(raw) if cfg.get("apiKey"): cfg["apiKey"] = decrypt_text(str(cfg["apiKey"])) return provider_id, {"meta": meta, "cfg": cfg} def provider_status() -> dict: """Статус для UI: кто активен, есть ли ключ, base, модель (ключ маскируется).""" provider_id, data = _cfg() meta = data["meta"] cfg = data["cfg"] key = str(cfg.get("apiKey") or "") return { "provider": provider_id, "name": meta["name"], "base": cfg.get("baseUrl") or meta["base"], "model": cfg.get("model") or (meta["models"] or [""])[0], "local": bool(meta.get("local")), "keySet": bool(key), "keyMasked": mask_key(key), } def mask_key(key: str) -> str: if not key: return "" if len(key) <= 8: return key[0] + "…" return f"{key[:4]}…{key[-4:]}" # ── «Сфера и ключи»: подстановка в промпты из настроек пользователя ──────── def fill_prompt(prompt: str) -> str: """Заменяет в тексте промпта {domain} и {keywords} значениями из настроек. Настройки задаются в UI (вкладка «Сфера и ключи»), поэтому ИИ-классификатор работает для любой сферы: разработка, дизайн, недвижимость, стройка и т.п. """ domain = str(store.get_setting("domainDescription") or "").strip() if not domain: domain = "Универсально: заявка = конкретный запрос на услугу/товар/работу или найм человека." raw_kws = store.get_setting("domainKeywords") or [] if isinstance(raw_kws, str): raw_kws = [raw_kws] kws = [str(k).strip() for k in raw_kws if str(k).strip()] kws_text = ", ".join(kws[:60]) if kws else "(не заданы — определяй по тексту)" return (prompt or "").replace("{domain}", domain).replace("{keywords}", kws_text) async def chat_json(system: str, user: str, max_retries: int = 2, max_tokens: int = 8000) -> dict: """Единая точка вызова выбранной модели. Возвращает dict (JSON-ответ). Модель иногда отвечает HTTP 200 с пустым/не-JSON содержимым (особенно на больших запросах или при перегрузке API) — делаем несколько попыток с нарастающей паузой, чтобы не сыпать «ИИ недоступен» из-за разового сбоя. """ provider_id, data = _cfg() meta = data["meta"] cfg = data["cfg"] base = str(cfg.get("baseUrl") or meta["base"]).rstrip("/") model = cfg.get("model") or (meta["models"] or [""])[0] api_key = str(cfg.get("apiKey") or "") last_err: Exception | None = None last_raw = "" for attempt in range(max_retries + 1): try: if meta.get("api_style") == "anthropic": text = await _call_anthropic(base, api_key, model, system, user, max_tokens) else: text = await _call_openai(base, api_key, model, system, user, max_tokens) last_raw = text return extract_json(text) except Exception as exc: # noqa: BLE001 — пробуем ещё раз last_err = exc if attempt < max_retries: await asyncio_sleep(0.8 + 1.2 * attempt) # 0.8с, 2с, 3.2с log.warning( "AI call failed (%s, попыток %d): %s | ответ: %r", meta["name"], max_retries + 1, last_err, last_raw[:200], ) raise RuntimeError( f"ИИ ({meta['name']}) не ответил корректно — повторите попытку через несколько секунд" ) async def asyncio_sleep(secs: float) -> None: import asyncio await asyncio.sleep(secs) async def _call_openai(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str: url = base + "/chat/completions" headers = {"Content-Type": "application/json"} if api_key: headers["Authorization"] = f"Bearer {api_key}" body = { "model": model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], "temperature": 0.2, "max_tokens": max_tokens, } async with httpx.AsyncClient(timeout=90) as client: resp = await client.post(url, headers=headers, json=body) resp.raise_for_status() payload = resp.json() try: msg = payload["choices"][0]["message"] except (KeyError, IndexError, TypeError): raise ValueError(f"неожиданный ответ API: {str(payload)[:200]}") content = msg.get("content") if not content and msg.get("reasoning_content"): # модель «подумала», но не дала ответа (переполнение/обрыв) — считаем сбоем и повторим raise ValueError("модель вернула только reasoning без ответа") return str(content or "") async def _call_anthropic(base: str, api_key: str, model: str, system: str, user: str, max_tokens: int = 8000) -> str: url = base.rstrip("/") + "/v1/messages" headers = { "Content-Type": "application/json", "x-api-key": api_key, "anthropic-version": "2023-06-01", } body = { "model": model, "max_tokens": max_tokens, "system": system, "messages": [{"role": "user", "content": user}], } async with httpx.AsyncClient(timeout=60) as client: resp = await client.post(url, headers=headers, json=body) resp.raise_for_status() payload = resp.json() return "".join(blk.get("text", "") for blk in payload.get("content", [])) def extract_json(text: str) -> dict: raw = text.strip() fence = re.search(r"```(?:json)?\s*(.*?)```", raw, re.S) if fence: raw = fence.group(1).strip() start, end = raw.find("{"), raw.rfind("}") if start >= 0 and end > start: raw = raw[start : end + 1] return json.loads(raw) # ── Задачи ──────────────────────────────────────────────────────────────── async def filter_incoming(text: str) -> dict: """Этап 2 (ИИ-фильтр). Если выключен — считаем пропущенным.""" if not store.get_setting("aiFilterEnabled"): return {"pass": True, "reason": None, "skipped": True} prompt = fill_prompt(store.get_setting("aiFilterPrompt")) out = await chat_json(prompt, f"Сообщение:\n{text[:4000]}") return { "pass": bool(out.get("pass", True)), "reason": out.get("reason"), "skipped": False, } def _learning_examples(limit: int = 8) -> list[dict]: """Примеры разметки пользователя для few-shot классификации.""" rows = store.query( "SELECT l.source_msg AS msg, ll.to_col " "FROM learning_log ll JOIN leads l ON l.id = ll.lead_id " "WHERE ll.action IN ('move','restore') AND l.source_msg <> '' " "ORDER BY ll.created_at DESC LIMIT ?", [limit], ) examples = [] for r in rows: if r["to_col"] in ("trash", "archive"): continue examples.append({"text": (r["msg"] or "")[:500], "board": r["to_col"]}) return examples async def classify(message_text: str) -> dict: """Полный разбор лида. Возвращает сырой словарь модели. Колонка для ИИ — это набор критериев (правила), а не просто название: в промпт уходят правила колонок, чтобы модель выбирала осмысленно. """ from .rules import describe as describe_rules # в классификации участвуют только принятые колонки (не ИИ-предложения) boards = store.query( "SELECT id, name, description, keywords, rules FROM boards WHERE suggested = FALSE ORDER BY pos" ) lines = [] for b in boards: rules = json.loads(b["rules"] or "{}") if b["rules"] else {} has_rules = any(rules.get(k) for k in ("direction", "keywords", "stack", "grade", "budget")) if has_rules: suffix = f" (критерии: {describe_rules(rules)})" else: kws = json.loads(b["keywords"] or "[]")[:8] suffix = f" (ключевые слова: {', '.join(kws)})" if kws else "" line = f"- {b['id']}: {b['name']}{suffix}" if b.get("description"): line += f" — {str(b['description']).strip()[:160]}" lines.append(line) board_map = "\n".join(lines) or "- (колонок пока нет — верните board: null)" examples = _learning_examples() user = ( f"Доски: {board_map}\n\n" + ("Примеры разметки пользователя:\n" + "\n".join( f"текст: {e['text']}\n→ колонка: {e['board']}" for e in examples ) + "\n\n" if examples else "") + f"Новое сообщение:\n{message_text[:5000]}" ) prompt = fill_prompt(store.get_setting("aiPrompt")) # отдельный промпт структуры карточки («О заявке») — задаёт поля контента, # чтобы все карточки имели одинаковую структуру текста card = fill_prompt(store.get_setting("cardPrompt") or "") if card: prompt = prompt + "\n\n" + card return await chat_json(prompt, user) def normalize_dedup(text: str) -> str: """Дедупликация по нормализованному тексту (регистр и спецсимволы).""" import hashlib import re as _re norm = _re.sub(r"[^\wа-яё]+", "", text.casefold()) return hashlib.sha1(norm.encode()).hexdigest() # Синонимы валют из ответов ИИ → коды хранения (согласовано с rules._CUR_*) _CUR_ALIASES = { "USD": "USD", "$": "USD", "US$": "USD", "ДОЛЛАР": "USD", "ДОЛЛАРОВ": "USD", "ДОЛЛ": "USD", "БАКС": "USD", "БАКСОВ": "USD", "EUR": "EUR", "€": "EUR", "ЕВРО": "EUR", "RUB": "RUB", "RUR": "RUB", "₽": "RUB", "РУБ": "RUB", "РУБЛЕЙ": "RUB", "РУБЛИ": "RUB", "РУБЛЬ": "RUB", "РУБЛЯ": "RUB", "GBP": "GBP", "£": "GBP", "CNY": "CNY", "¥": "CNY", "USDT": "USDT", "₮": "USDT", } def _norm_currency(raw) -> str | None: """Приводит название/символ валюты из ИИ к коду (USD/EUR/RUB/…).""" s = str(raw or "").strip().upper() if not s: return None if s in _CUR_ALIASES: return _CUR_ALIASES[s] letters = re.sub(r"[^A-ZА-Я]", "", s) if letters in _CUR_ALIASES: return _CUR_ALIASES[letters] if len(s) == 3 and s.isalpha(): return s return None def _budget_num(v): """Число из значения бюджета ИИ: «2к»/«2К» → 2000, «2000₽»/«2000р» → 2000.""" if v is None: return None s = str(v).strip().casefold().replace("\u00a0", "").replace(" ", "") if not s: return None mult = 1.0 if s.endswith(("к", "k")): mult = 1000.0 s = s[:-1] if s.endswith("руб"): s = s[:-3] elif s.endswith(("р", "₽")): s = s[:-1] try: x = float(s.replace(",", ".")) * mult except ValueError: return None return None if x == 0 else x def clean_budget(budget) -> dict | None: """Нормализация бюджета из ИИ/локального разбора. Соглашение хранения (и отображения в UI): одна сумма X → {from: X, to: X} «до X» → {from: None, to: X} диапазон «от X до Y» → {from: X, to: Y} from=0 трактуется как отсутствие нижней границы («от 0 до X» == «до X»). """ if not isinstance(budget, dict): return None cur = _norm_currency(budget.get("currency") or budget.get("cur")) if not cur: return None def num(v): return _budget_num(v) f, t = num(budget.get("from")), num(budget.get("to")) if f is None and t is None: return None if t is None: t = f # одна сумма или «от X» без верхней границы return {"from": f, "to": t, "currency": cur} def budget_to_target(budget: dict | None) -> dict: """Пересчёт «один раз при поступлении» в целевую валюту по текущим курсам.""" if not budget or not budget.get("currency"): return {"convFrom": None, "convTo": None, "convCur": ""} cur = budget["currency"] target = store.get_setting("targetCurrency") or "RUB" if not store.get_setting("conversionOn"): return {"convFrom": None, "convTo": None, "convCur": ""} from_, to_ = budget.get("from"), budget.get("to") c_from = convert_amount(from_, cur, target) if from_ is not None else None c_to = None if to_ is not None: c_to = convert_amount(to_, cur, target) elif from_ is not None: c_to = c_from return {"convFrom": c_from, "convTo": c_to, "convCur": target}