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