Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""ИИ-предложения колонок по анализу «Неразобранного».
|
||||
|
||||
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
|
||||
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
|
||||
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (suggested=TRUE) с обоснованием (note), по каким
|
||||
критериям собрана. Пользователь открывает предложение, смотрит карточки и
|
||||
решает: принять (можно переименовать/поправить правила) или удалить.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from ..db import store
|
||||
from ..sse import broker
|
||||
from . import ai as ai_svc
|
||||
|
||||
log = logging.getLogger("leadradar.suggest")
|
||||
|
||||
SUGGEST_PROMPT = """Ты — аналитик входящих заявок. Перед тобой пронумерованные сообщения (номера 1..N), которые не подошли ни под одну существующую колонку. Сгруппируй их в 2–4 ОСМЫСЛЕННЫЕ тематические колонки.
|
||||
Сфера/что считается заявкой:
|
||||
{domain}
|
||||
Колонка — это не отдельный предмет и не каждое слово по отдельности, а направление с набором родственных признаков (тип работ/услуг, предметы, технологии, материалы). Пример: «Telegram-боты» — направление: чат-боты/автоматизация; предметы/технологии: Python, aiogram; слова: бот, telegram, автоответчик.
|
||||
Правила:
|
||||
- группируй повторяющиеся темы: в каждую колонку бери минимум 2 сообщения;
|
||||
- не предлагай колонки, похожие на уже существующие;
|
||||
- 2–4 колонки максимум; если ничего общего нет — верни пустой список.
|
||||
Для каждой колонки верни:
|
||||
- name — короткое название (2–4 слова);
|
||||
- description — короткое описание колонки (1–2 предложения): что за заявки и для кого;
|
||||
- direction — направление/тип задач (2–5 слов или фраз);
|
||||
- stack — что фигурирует в заявках: предметы, услуги, технологии, материалы (1–6);
|
||||
- grade — уровень/грейд, если ярко выражен (например: ["middle"]), иначе пустой список;
|
||||
- keywords — 3–8 ключевых слов/фраз, по которым узнаётся такая заявка;
|
||||
- messages — НОМЕРА сообщений из списка, которые относятся к этой колонке (минимум 2, максимум 12);
|
||||
- reason — обоснование в 1 предложение: что за тема, сколько карточек и что в них общего.
|
||||
Верни строго JSON:
|
||||
{ "columns": [ { "name": "…", "description": "…", "direction": ["…"], "stack": ["…"], "grade": ["…"], "keywords": ["…"], "messages": [1, 5], "reason": "…" } ] }"""
|
||||
|
||||
KEYWORDS_PROMPT = """Ты — аналитик входящих сообщений. Ниже — реальные сообщения, которые уже признаны заявками/лидами (и часть — обычный флуд каналов).
|
||||
Сфера/что считается заявкой:
|
||||
{domain}
|
||||
|
||||
Выдели общие слова-МАРКЕРЫ, по которым сообщение можно отнести к заявкам этой сферы (а не к флуду): типовые предметы/услуги/работы, глаголы-действия («куплю», «нужен», «сниму», «отремонтировать»), статусные слова («срочно», «под ключ», «бюджет»).
|
||||
Верни строго JSON с плоским списком от 8 до 40 ключевых слов/фраз (слова в нижнем регистре, без знаков препинания):
|
||||
{ "keywords": ["…", "…"] }"""
|
||||
|
||||
MIN_INBOX = 6 # минимум карточек в «Неразобранном» для анализа
|
||||
MIN_INBOX_GROUP = 2 # минимум карточек в одной ИИ-колонке
|
||||
MAX_TEXT = 12 # сколько сообщений берём в анализ (ИИ обрывает длинный JSON)
|
||||
COOLDOWN_S = 20 * 60 # как часто автоматически переспрашиваем
|
||||
KEY = "lastSuggestAt"
|
||||
|
||||
|
||||
def _similar_exists(name: str) -> bool:
|
||||
low = name.casefold()
|
||||
rows = store.query("SELECT id, name FROM boards")
|
||||
for r in rows:
|
||||
n = str(r["name"]).casefold()
|
||||
if low == n or low in n or n in low:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _rules_for(item: dict) -> dict:
|
||||
"""Правила колонки из ИИ-ответа: направление/слова/стек/грейд (любое из условий)."""
|
||||
return {
|
||||
"mode": "any",
|
||||
"direction": [str(x).strip().lower() for x in (item.get("direction") or []) if str(x).strip()][:6],
|
||||
"keywords": [str(x).strip().lower() for x in (item.get("keywords") or []) if str(x).strip()][:8],
|
||||
"stack": [str(x).strip().lower() for x in (item.get("stack") or []) if str(x).strip()][:8],
|
||||
"grade": [str(x).strip().lower() for x in (item.get("grade") or []) if str(x).strip()][:6],
|
||||
}
|
||||
|
||||
|
||||
async def suggest_from_inbox(force: bool = False) -> dict:
|
||||
"""Анализ «Неразобранного» и создание колонок-предложений с обоснованием.
|
||||
|
||||
ИИ группирует пронумерованные сообщения и для каждой колонки возвращает
|
||||
номера сообщений (messages). Карточки раскладываются именно по этим
|
||||
номерам, а не строгим match_text по сгенерированным правилам — иначе
|
||||
колонка-предложение часто остаётся пустой (правила ИИ приблизительные).
|
||||
"""
|
||||
# Предложения колонок — это вызов ИИ: при выключенном ИИ (aiEnabled=false,
|
||||
# режим «только фильтр + ML») автоцикл не должен дёргать провайдера.
|
||||
if not force and not store.get_setting("aiEnabled"):
|
||||
return {"ok": False, "reason": "ИИ выключен — предложения колонок недоступны"}
|
||||
if not force:
|
||||
# не плодим предложения, пока пользователь не разобрался со старыми
|
||||
pending = store.scalar("SELECT count(*) FROM boards WHERE suggested = TRUE")
|
||||
if pending:
|
||||
return {"ok": False, "reason": f"сначала решите судьбу {pending} предложенных колонок"}
|
||||
last = int(store.get_setting(KEY) or 0)
|
||||
if time.time() - last < COOLDOWN_S:
|
||||
return {"ok": False, "reason": "недавно предлагали — подождите", "cooldown": True}
|
||||
inbox = store.query(
|
||||
"SELECT id, source_msg FROM leads WHERE col = 'inbox' AND source_msg <> '' "
|
||||
"ORDER BY received_at DESC LIMIT ?",
|
||||
[MAX_TEXT],
|
||||
)
|
||||
if len(inbox) < MIN_INBOX:
|
||||
return {"ok": False, "reason": f"мало карточек в «Неразобранном» (нужно от {MIN_INBOX})"}
|
||||
rows = [(str(r["id"]), str(r["source_msg"])) for r in inbox]
|
||||
texts = [t[:180] for _, t in rows]
|
||||
existing = [str(r["name"]) for r in store.query("SELECT name FROM boards WHERE suggested = FALSE ORDER BY pos")]
|
||||
user = (
|
||||
f"Существующие колонки: {', '.join(existing) if existing else 'нет'}\n\n"
|
||||
"Сообщения:\n" + "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts, start=1))
|
||||
)
|
||||
try:
|
||||
out = await ai_svc.chat_json(ai_svc.fill_prompt(SUGGEST_PROMPT), user, max_retries=2, max_tokens=16000)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("suggest failed: %s", exc)
|
||||
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
|
||||
cols = out.get("columns") if isinstance(out, dict) else None
|
||||
if not isinstance(cols, list) or not cols:
|
||||
log.info("suggest: ИИ не предложил колонок")
|
||||
return {"ok": False, "reason": "ИИ не предложил колонок"}
|
||||
|
||||
# диагностика: что именно предложил ИИ (имена + число карточек)
|
||||
log.info(
|
||||
"suggest: ИИ предложил %d кандидатов: %s",
|
||||
len(cols),
|
||||
"; ".join(
|
||||
f"{str(c.get('name'))[:40]}(msg={c.get('messages')})" for c in cols if isinstance(c, dict)
|
||||
)[:300],
|
||||
)
|
||||
|
||||
# номера, на которые уже «потратились» предыдущие колонки этого прогона
|
||||
used_msg: set[int] = set()
|
||||
created = 0
|
||||
for item in cols[:4]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = str(item.get("name") or "").strip()
|
||||
if len(name) < 2 or len(name) > 40:
|
||||
continue
|
||||
if _similar_exists(name):
|
||||
continue
|
||||
nums = [int(x) for x in (item.get("messages") or []) if str(x).isdigit()]
|
||||
nums = [n for n in nums if 1 <= n <= len(rows) and n not in used_msg]
|
||||
if len(nums) < MIN_INBOX_GROUP:
|
||||
continue # колонка без явных карточек не создаётся
|
||||
used_msg.update(nums)
|
||||
rules = _rules_for(item)
|
||||
note = _make_note(item, texts, rules, nums)
|
||||
description = str(item.get("description") or "").strip()[:300]
|
||||
board = _store_suggested(name, rules, note, description)
|
||||
if not board:
|
||||
continue
|
||||
assigned = _assign_ids(rows, nums, board["id"])
|
||||
if not assigned:
|
||||
# ничего не удалось положить — пустое предложение не нужно
|
||||
_rollback_suggested(board["id"])
|
||||
continue
|
||||
created += 1
|
||||
|
||||
if not created:
|
||||
return {"ok": False, "reason": "похожие колонки уже есть или нечего сгруппировать"}
|
||||
store.set_setting(KEY, int(time.time()))
|
||||
await broker.publish("boards_changed", {})
|
||||
await broker.publish_toast(f"ИИ предложил колонок: {created} — откройте и решите", "sparkles")
|
||||
return {"ok": True, "created": created}
|
||||
|
||||
|
||||
async def suggest_domain_keywords() -> dict:
|
||||
"""«Предложить ключи ИИ»: по вашим карточкам выделяет общие слова-маркеры сферы.
|
||||
|
||||
Возвращает кандидатов — пользователь смотрит, правит и сохраняет в настройках
|
||||
«Сфера и ключи» (общие ключи) или использует для правил колонок.
|
||||
"""
|
||||
rows = store.query(
|
||||
"SELECT source_msg FROM leads WHERE col <> 'trash' AND col <> 'archive' AND source_msg <> '' "
|
||||
"ORDER BY received_at DESC LIMIT 40"
|
||||
)
|
||||
texts = [str(r["source_msg"])[:350] for r in rows]
|
||||
if len(texts) < 3:
|
||||
return {"ok": False, "reason": "мало карточек — сначала накопите заявки (нужно хотя бы 3)"}
|
||||
user = "\n".join(f"{i + 1}. {t}" for i, t in enumerate(texts))
|
||||
try:
|
||||
out = await ai_svc.chat_json(ai_svc.fill_prompt(KEYWORDS_PROMPT), user, max_retries=2)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("suggest keywords failed: %s", exc)
|
||||
return {"ok": False, "reason": f"ИИ недоступен: {exc}"}
|
||||
kws = out.get("keywords") if isinstance(out, dict) else None
|
||||
if not isinstance(kws, list) or not kws:
|
||||
return {"ok": False, "reason": "ИИ не смог выделить ключи — попробуйте ещё раз"}
|
||||
clean: list[str] = []
|
||||
for k in kws:
|
||||
s = str(k).strip().casefold().strip(".,;:«»\"'()!#")
|
||||
if s and len(s) <= 40 and s not in clean:
|
||||
clean.append(s)
|
||||
return {"ok": True, "keywords": clean[:60]}
|
||||
|
||||
|
||||
def _make_note(item: dict, texts: list[str], rules: dict, nums: list[int]) -> str:
|
||||
reason = str(item.get("reason") or "").strip()
|
||||
direction = " · ".join(str(x) for x in (item.get("direction") or [])[:3])
|
||||
base = f"Обоснование ИИ: {reason}" if reason else (
|
||||
f"Обоснование ИИ: направление — {direction}" if direction else
|
||||
"Обоснование ИИ: повторяющиеся заявки одной темы"
|
||||
)
|
||||
# сколько карточек из выборки реально попадёт в колонку (по номерам ИИ)
|
||||
matched = sum(1 for n in nums if 1 <= n <= len(texts))
|
||||
if matched:
|
||||
base += f" · карточек в колонке: {matched}"
|
||||
return base[:400]
|
||||
|
||||
|
||||
def _store_suggested(name: str, rules: dict, note: str, description: str = "") -> dict | None:
|
||||
from . import leads as leads_svc
|
||||
|
||||
keywords = rules.get("keywords") or []
|
||||
board = leads_svc.create_board(
|
||||
name, suggested=True, keywords=keywords, rules=rules, note=note, description=description
|
||||
)
|
||||
return leads_svc.board_by_id(board["id"])
|
||||
|
||||
|
||||
def _assign_ids(rows: list[tuple[str, str]], nums: list[int], board_id: str) -> int:
|
||||
"""Раскладывает в колонку-предложение карточки по номерам, которые назвал ИИ.
|
||||
|
||||
rows — список (id, source_msg) в том же порядке, в каком сообщения уходили
|
||||
в промпт (1..N). Возвращает число реально перенесённых карточек.
|
||||
"""
|
||||
assigned = 0
|
||||
for n in nums:
|
||||
if not (1 <= n <= len(rows)):
|
||||
continue
|
||||
lead_id, _ = rows[n - 1]
|
||||
still = store.scalar("SELECT 1 FROM leads WHERE id = ? AND col = 'inbox'", [lead_id])
|
||||
if not still:
|
||||
continue # карточка уже разобрана другим предложением/пользователем
|
||||
store.execute(
|
||||
"UPDATE leads SET col = ?, is_new = TRUE, prev_col = 'inbox' WHERE id = ? AND col = 'inbox'",
|
||||
[board_id, lead_id],
|
||||
)
|
||||
assigned += 1
|
||||
return assigned
|
||||
|
||||
|
||||
def _rollback_suggested(board_id: str) -> None:
|
||||
"""Удаляет пустую колонку-предложение (в неё ничего не попало)."""
|
||||
from . import leads as leads_svc
|
||||
|
||||
store.execute("UPDATE leads SET col = 'inbox' WHERE col = ?", [board_id])
|
||||
store.execute("DELETE FROM boards WHERE id = ?", [board_id])
|
||||
Reference in New Issue
Block a user