mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
# Классификатор постов: словарный фильтр (MUST) + LLM-уточнение (qwen3:8b-nothink).
|
||||
# По спеке: сначала keywords.py (без LLM), затем для кандидатов — Ollama.
|
||||
# Если модель недоступна — пост остаётся unclassified (не теряется).
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from classifier.keywords import find_direction, DIRECTIONS_CANON
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
|
||||
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b-nothink")
|
||||
TIMEOUT = float(os.getenv("CLASSIFY_TIMEOUT", "30"))
|
||||
|
||||
|
||||
def classify_text(post: dict) -> dict:
|
||||
"""Классифицирует текст поста. Возвращает dict с direction, relevance, interest, summary, classified.
|
||||
|
||||
Сначала словарный фильтр (быстрый, без LLM). Если направление найдено —
|
||||
пробуем LLM-уточнение (relevance/interest/summary). При недоступности LLM —
|
||||
фолбэк: direction из словаря, relevance=low, interest=1, classified=False.
|
||||
|
||||
СВОЙ контент (is_own=1): «сильный кандидат» — при словарном попадании
|
||||
relevance=critical, classified=True даже без LLM (фолбэк 'dict-own').
|
||||
Если словарь не дал направление — классификации нет, пост не теряется.
|
||||
|
||||
post: {"text": str, "views": int, "reactions_total": int, "is_own": int, ...}
|
||||
"""
|
||||
text = (post.get("text") or "").strip()
|
||||
views = int(post.get("views") or 0)
|
||||
reactions = int(post.get("reactions_total") or 0)
|
||||
is_own = int(post.get("is_own") or 0) == 1
|
||||
|
||||
direction = find_direction(text)
|
||||
if not direction:
|
||||
# словарь не дал попадания — всё равно пробуем LLM (MUST: не терять посты)
|
||||
try:
|
||||
llm = call_ollama(text, views, reactions, None)
|
||||
d = llm.get("direction")
|
||||
if d and d in DIRECTIONS_CANON:
|
||||
return {
|
||||
"direction": d,
|
||||
"relevance": llm.get("relevance") or "low",
|
||||
"interest": clamp_interest(llm.get("interest")),
|
||||
"summary": llm.get("summary") or "",
|
||||
"classified": True,
|
||||
"method": "llm",
|
||||
}
|
||||
# LLM отработала, но не дала каноническое направление — не классифицируем
|
||||
return {
|
||||
"direction": None,
|
||||
"relevance": "low",
|
||||
"interest": 1,
|
||||
"summary": "",
|
||||
"classified": False,
|
||||
"method": "llm-no-direction",
|
||||
}
|
||||
except Exception as e:
|
||||
# LLM недоступна — пост остаётся неклассифицированным (не теряется)
|
||||
return {
|
||||
"direction": None,
|
||||
"relevance": "low",
|
||||
"interest": 1,
|
||||
"summary": "",
|
||||
"classified": False,
|
||||
"method": f"dict-miss llm-err ({type(e).__name__})",
|
||||
}
|
||||
|
||||
# словарный фильтр сработал — кандидат; пробуем LLM
|
||||
try:
|
||||
llm = call_ollama(text, views, reactions, direction)
|
||||
return {
|
||||
"direction": llm.get("direction") or direction,
|
||||
"relevance": llm.get("relevance") or "low",
|
||||
"interest": clamp_interest(llm.get("interest")),
|
||||
"summary": llm.get("summary") or "",
|
||||
"classified": True,
|
||||
"method": "llm",
|
||||
}
|
||||
except Exception as e:
|
||||
# СВОЙ контент: сильный кандидат даже без LLM (словарь дал направление)
|
||||
if is_own:
|
||||
return {
|
||||
"direction": direction,
|
||||
"relevance": "critical",
|
||||
"interest": 1,
|
||||
"summary": "",
|
||||
"classified": True,
|
||||
"method": f"dict-own ({type(e).__name__})",
|
||||
}
|
||||
# LLM недоступна — фолбэк без LLM (MUST: пост не теряется)
|
||||
return {
|
||||
"direction": direction,
|
||||
"relevance": "low",
|
||||
"interest": 1,
|
||||
"summary": "",
|
||||
"classified": False,
|
||||
"method": f"dict-only ({type(e).__name__})",
|
||||
}
|
||||
|
||||
|
||||
def call_ollama(text: str, views: int, reactions: int, direction: str) -> dict:
|
||||
"""Вызывает qwen3:8b-nothink (OpenAI-совместимый /v1, think:false) и возвращает JSON."""
|
||||
prompt = f"""Ты — классификатор новостей. Определи {{
|
||||
"direction": "одно из: {', '.join(sorted(DIRECTIONS_CANON))}",
|
||||
"relevance": "critical|high|low",
|
||||
"interest": 1-5,
|
||||
"summary": "краткое резюме 1-2 предложения"
|
||||
}} для поста.
|
||||
|
||||
Пост (направление по словарю: {direction}, views={views}, reactions={reactions}):
|
||||
{text[:2000]}
|
||||
|
||||
Ответь ТОЛЬКО JSON."""
|
||||
r = httpx.post(
|
||||
f"{OLLAMA_URL}/v1/chat/completions",
|
||||
json={
|
||||
"model": OLLAMA_MODEL,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"temperature": 0.2,
|
||||
"stream": False,
|
||||
"think": False, # qwen3: отключаем reasoning через extra_body (OpenAI-совместимый)
|
||||
},
|
||||
timeout=TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
content = (data.get("choices") or [{}])[0].get("message", {}).get("content") or ""
|
||||
# вытаскиваем JSON из ответа (может быть с ```json обёрткой)
|
||||
content = content.strip()
|
||||
if content.startswith("```"):
|
||||
content = content.strip("`")
|
||||
if content.startswith("json"):
|
||||
content = content[4:].strip()
|
||||
return json.loads(content)
|
||||
|
||||
|
||||
def clamp_interest(v) -> int:
|
||||
try:
|
||||
return max(1, min(5, int(v)))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def classify_posts_in_db(db_path: str, limit: int = 200, direction: str | None = None):
|
||||
"""Обрабатывает неклассифицированные посты в SQLite. Возвращает (processed, classified, llm_ok)."""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
# посты без классификации (classified не задан — status='new' и нет direction?)
|
||||
# в схеме пока нет колонок классификации; добавляем по ходу (схема расширяется)
|
||||
cols = [r[1] for r in cur.execute("PRAGMA table_info(posts)")]
|
||||
if "direction" not in cols:
|
||||
cur.execute("ALTER TABLE posts ADD COLUMN direction TEXT")
|
||||
cur.execute("ALTER TABLE posts ADD COLUMN relevance TEXT")
|
||||
cur.execute("ALTER TABLE posts ADD COLUMN interest INTEGER")
|
||||
cur.execute("ALTER TABLE posts ADD COLUMN summary TEXT")
|
||||
cur.execute("ALTER TABLE posts ADD COLUMN classified INTEGER DEFAULT 0")
|
||||
conn.commit()
|
||||
cols = [r[1] for r in cur.execute("PRAGMA table_info(posts)")]
|
||||
|
||||
where = "WHERE classified IS NULL OR classified=0"
|
||||
if direction:
|
||||
where += f" AND direction IS NULL" # не классифицированы в этом направлении
|
||||
|
||||
rows = cur.execute(
|
||||
f"SELECT id, text, views, reactions_total, is_own FROM posts {where} ORDER BY is_own DESC, id LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
|
||||
processed = classified = llm_ok = 0
|
||||
for row in rows:
|
||||
res = classify_text(dict(row))
|
||||
if res["classified"]:
|
||||
classified += 1
|
||||
if res["method"] == "llm":
|
||||
llm_ok += 1
|
||||
cur.execute(
|
||||
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
||||
(res["direction"], res["relevance"], res["interest"], res["summary"],
|
||||
1 if res["classified"] else 0, row["id"]),
|
||||
)
|
||||
# мультинаправления: все словарные попадания → classifications (для fan-out)
|
||||
from classifier.keywords import find_directions_all
|
||||
dirs_all = find_directions_all(row["text"] or "")
|
||||
if res["direction"] and res["direction"] not in dirs_all:
|
||||
dirs_all.append(res["direction"])
|
||||
for d in dirs_all:
|
||||
exists = cur.execute(
|
||||
"SELECT 1 FROM classifications WHERE post_id=? AND direction=? LIMIT 1",
|
||||
(row["id"], d),
|
||||
).fetchone()
|
||||
if not exists:
|
||||
cur.execute(
|
||||
"""INSERT INTO classifications (post_id, direction, relevance, interest, summary, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(row["id"], d, res["relevance"], res["interest"], res["summary"],
|
||||
res.get("method") or "classify"),
|
||||
)
|
||||
processed += 1
|
||||
if processed % 5 == 0 or processed == len(rows):
|
||||
conn.commit() # понемногу коммитим — не держать долгую транзакцию
|
||||
print(f" ...{processed}/{len(rows)} (LLM: {llm_ok})", flush=True)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return processed, classified, llm_ok
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="VESTI classifier")
|
||||
ap.add_argument("--db", default=str(Path(__file__).resolve().parent.parent / "db" / "vesti.db"))
|
||||
ap.add_argument("--direction", help="направление (необязательно)")
|
||||
ap.add_argument("--limit", type=int, default=200)
|
||||
args = ap.parse_args()
|
||||
|
||||
proc, cls, llm = classify_posts_in_db(args.db, args.limit, args.direction)
|
||||
print(f"Обработано: {proc}, классифицировано (LLM+словарь): {cls}, из них через LLM: {llm}", flush=True)
|
||||
# показать примеры
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(args.db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
for r in conn.execute("SELECT id, direction, relevance, interest, substr(summary,1,60) s FROM posts WHERE classified=1 LIMIT 5"):
|
||||
print(f" #{r['id']} {r['direction']} {r['relevance']} interest={r['interest']}: {r['s']}")
|
||||
conn.close()
|
||||
@@ -0,0 +1,120 @@
|
||||
# Классификатор: словари ключевых слов по направлениям (MUST: словарный фильтр первым).
|
||||
# Направления расширяемы; для прототипа — linux (основное) + общие.
|
||||
# Канонический список направлений (AGENT.MD, веб-форма fan-out):
|
||||
DIRECTIONS_CANON = ["linux", "tech", "politics", "games", "electronics", "llm"]
|
||||
|
||||
DIRECTIONS = {
|
||||
"linux": {
|
||||
"keywords": [
|
||||
"linux", "kernel", "gnome", "kde", "distro", "distribution", "ubuntu", "fedora",
|
||||
"debian", "arch", "manjaro", "mint", "opensuse", "xfce", "wayland", "xorg",
|
||||
"systemd", "apt", "dnf", "pacman", "aur", "snap", "flatpak", "bash", "zsh",
|
||||
"terminal", "shell", "foss", "open source", "opensource", "поверх", "ядро",
|
||||
"дистрибутив", "пакетный менеджер", "реестр пакетов", "libreoffice", "gimp",
|
||||
"вокал", "linux-", "tux", "posix", "unix", "gnu", "kde plasma", "gtk", "qt",
|
||||
"docker", "podman", "k8s", "kubernetes", "контейнер", "образ", "контейнеризация",
|
||||
"self-host", "selfhost", "homelab", "сервер", "vps", "devops", "инфраструктура",
|
||||
"nginx", "postgres", "sqlite", "redis", "grafana", "prometheus", "ansible",
|
||||
"terraform", "caddy", "docker-compose", "compose", "proxy", "vpn", "wireguard",
|
||||
"ssh", "sftp", "tmux", "vim", "neovim", "emacs", "git", "github", "gitlab",
|
||||
"open source", "клонирование", "репозиторий", "пакет", "сборка", "компиляция",
|
||||
"cli", "консоль", "командная строка", "python", "c++", "rust", "go",
|
||||
"golang", "golang", "programming", "программирование", "разработка", "код", "скрипт",
|
||||
"автоматизация", "юникс", "линукс", "пентинг", "security", "безопасность",
|
||||
"шифрование", "gnupg", "firewall", "iptables", "nftables", "selinux", "apparmor",
|
||||
"btrfs", "ext4", "zfs", "файловая система", "lvm", "raid", "ssd", "nvme",
|
||||
"железо", "драйвер", "фирмware", "прошивка", "bios", "uefi", "grub",
|
||||
"безголовый", "headless", "микросервис", "api", "rest", "http", "tcp", "udp",
|
||||
],
|
||||
# дополнительные точные фразы (более сильные)
|
||||
"phrases": ["linux", "ubuntu", "debian", "arch linux", "fedora", "kernel", "gnome", "kde"],
|
||||
},
|
||||
"tech": {
|
||||
"keywords": [
|
||||
# dev (бывший отдельный; разработка/программирование — tech)
|
||||
"разработка", "программирование", "код", "python", "golang", "golang", "rust", "javascript",
|
||||
"typescript", "node", "react", "vue", "next.js", "docker", "kubernetes", "api",
|
||||
"backend", "frontend", "фреймворк", "алгоритм", "open source", "github", "gitlab",
|
||||
"ci/cd", "тестирование", "деплой", "микросервисы", "база данных", "sql", "nosql",
|
||||
"инженер", "разработчик", "программист", "компилятор", "интерпретатор", "ide",
|
||||
"дебаггер", "рефакторинг", "собеседование", "технологии",
|
||||
# tech (бывший)
|
||||
"технологии", "гаджет", "смартфон", "процессор", "чип", "android", "iphone",
|
||||
"apple", "google", "microsoft", "windows", "железо", "компьютер", "ноутбук",
|
||||
"сервер", "storage", "хранилище", "облако", "cloud", "интернет вещей", "iot",
|
||||
"робот", "робототехника", "экран", "дисплей", "батарея", "зарядка", "usb-c",
|
||||
"хакер", "уязвимость", "эксплойт", "zero-day", "шифрование", "приватность",
|
||||
],
|
||||
"phrases": ["смартфон", "гаджет", "чип", "apple", "технологии", "программирование", "разработка", "software", "developer", "code"],
|
||||
},
|
||||
"politics": {
|
||||
"keywords": [
|
||||
"политика", "президент", "выборы", "закон", "законопроект", "госдума", "кремль",
|
||||
"дума", "правительство", "министр", "санкции", "война", "мир", "конфликт",
|
||||
"армия", "нато", "украина", "россия", "сша", "китай", "евросоюз", "телеграм-канал",
|
||||
"депутат", "партия", "референдум", "голосование", "политик", "геополитика",
|
||||
"международный", "амбассадор", "посол", "договор", "переговоры",
|
||||
],
|
||||
"phrases": ["политика", "выборы", "президент", "госдума", "санкции", "война"],
|
||||
},
|
||||
"games": {
|
||||
"keywords": [
|
||||
"игра", "игры", "гейминг", "gaming", "steam", "playstation", "xbox", "nintendo",
|
||||
"геймпад", "игровая консоль", "видеоигра", "мморпг", "шутер", "стратегия",
|
||||
"симулятор", "инди-игра", "киберспорт", "esports", "виртуальная реальность",
|
||||
"vr", "игровой движок", "unity", "unreal", "геймер",
|
||||
],
|
||||
"phrases": ["игра", "гейминг", "steam", "gaming"],
|
||||
},
|
||||
"electronics": {
|
||||
"keywords": [
|
||||
"электроника", "микроконтроллер", "arduino", "raspberry", "esp32", "esp8266",
|
||||
"пайка", "схема", "плата", "микросхема", "транзистор", "резистор", "конденсатор",
|
||||
"осциллограф", "мультиметр", "радио", "антенна", "sdr", "fpga", "промышленность",
|
||||
"электронный", "компонент", "датчик", "силовой", "реле",
|
||||
],
|
||||
"phrases": ["arduino", "raspberry", "fpga", "микроконтроллер"],
|
||||
},
|
||||
"llm": {
|
||||
"keywords": [
|
||||
"ии", "ai", "llm", "gpt", "нейросеть", "нейронная сеть", "deep learning",
|
||||
"машинное обучение", "ml", "модель", "обучение модели", "инференс", "файнтюнинг",
|
||||
"fine-tuning", "rlhf", "агент", "агенты", "генеративный", "стабильная диффузия",
|
||||
"stable diffusion", "midjourney", "чат-бот", "chatbot", "ассистент", "токен",
|
||||
"трансформер", "embedding", "векторная база", "rag", "pipeline", "инференс",
|
||||
"openai", "антропик", "claude", "gemini", "olama", "qwen",
|
||||
],
|
||||
"phrases": ["нейросеть", "llm", "чат-бот", "openai", "claude"],
|
||||
},
|
||||
}
|
||||
|
||||
import re
|
||||
|
||||
# Направление по умолчанию, если ничего не подошло
|
||||
DEFAULT_DIRECTION = "linux" # прототип: все посты про linux окружение
|
||||
|
||||
|
||||
def find_directions_all(text: str) -> list[str]:
|
||||
"""Возвращает ВСЕ направления, которым соответствует текст (для мультинаправлений/fan-out)."""
|
||||
text_low = (text or "").lower()
|
||||
hits = set()
|
||||
for direction, cfg in DIRECTIONS.items():
|
||||
# точные фразы
|
||||
if any(p.lower() in text_low for p in cfg.get("phrases", [])):
|
||||
hits.add(direction)
|
||||
continue
|
||||
# ключевые слова (по слову, re.search с границами)
|
||||
for kw in cfg.get("keywords", []):
|
||||
kw_low = kw.lower()
|
||||
if re.search(rf"\b{re.escape(kw_low)}\b", text_low):
|
||||
hits.add(direction)
|
||||
break
|
||||
return list(hits)
|
||||
|
||||
|
||||
def find_direction(text: str) -> str | None:
|
||||
"""Возвращает направление, если текст совпал со словарём (иначе None)."""
|
||||
dirs = find_directions_all(text)
|
||||
if dirs:
|
||||
return dirs[0]
|
||||
return None
|
||||
Reference in New Issue
Block a user