Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF, CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения), .editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize. Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
248 lines
15 KiB
Python
248 lines
15 KiB
Python
"""ИИ-предложения колонок по анализу «Неразобранного».
|
||
|
||
Колонка — не «на один скилл», а смысловая тема: направление + набор стека и
|
||
ключевых слов (+ бюджет при повторяемости). ИИ группирует карточки и создаёт
|
||
КОЛОНКИ-ПРЕДЛОЖЕНИЯ (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])
|