"""ИИ-предложения колонок по анализу «Неразобранного». Колонка — не «на один скилл», а смысловая тема: направление + набор стека и ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт КОЛОНКИ-ПРЕДЛОЖЕНИЯ (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])