Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -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}