Initial import: vesti.nixg.ru — новостной апрув-проект (web, crawler, classifier, publisher, openspec)

This commit is contained in:
kpa39l
2026-09-13 15:58:32 +00:00
commit c3f59f7b7a
113 changed files with 7065 additions and 0 deletions
+232
View File
@@ -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()