Files
wiki/skills/telegram-collector/scripts/analyzer.py
2026-04-12 21:55:33 +03:00

379 lines
16 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Двухпроходный анализ сообщений Telegram-группы.
Пасс 1: GPT-4o mini — извлечение структурированных фактов из чанков по 50 сообщений
Пасс 2: Claude Sonnet — синтез финального knowledge_base.md
Поддерживает возобновление: прогресс сохраняется в facts_partial.json.
Использует OPENAI_API_KEY и OPENROUTER_API_KEY из ~/.openclaw/.env
"""
import os, sys, json, time, argparse
from pathlib import Path
from datetime import datetime
sys.path.insert(0, '/home/node/.local/lib/python3.11/site-packages')
from dotenv import load_dotenv
load_dotenv(os.path.expanduser('~/.openclaw/.env'))
try:
import requests
except ImportError:
os.system('~/.local/bin/pip install --break-system-packages requests -q')
import requests
# --- Конфигурация ---
OPENAI_API_KEY = os.getenv('OPENAI_API_KEY')
OPENROUTER_API_KEY = os.getenv('OPENROUTER_API_KEY')
DATA_DIR = Path('/home/node/.openclaw/workspace/data/telegram-collector')
CHANNEL_DIR = DATA_DIR / 'raw' / '1242788123'
OUTPUT_DIR = DATA_DIR
FACTS_FILE = OUTPUT_DIR / 'facts_partial.json'
KB_FILE = OUTPUT_DIR / 'knowledge_base.md'
CHUNK_SIZE = 50
SKIP_TOPICS = {'117112'} # Опросы — не информативны
PASS1_MODEL = 'gpt-4o-mini'
PASS2_MODEL = 'anthropic/claude-sonnet-4-5' # через OpenRouter
FACT_CATEGORIES = ['repairs', 'models', 'locations', 'prices',
'riding_tips', 'tuning', 'donor_bikes', 'season']
PASS1_SYSTEM = """Ты анализируешь сообщения из русскоязычного Telegram-сообщества сноубайкеров.
Извлеки полезные конкретные факты и верни ТОЛЬКО валидный JSON без пояснений:
{
"repairs": ["конкретная проблема и/или решение"],
"models": ["модель/бренд сноубайка: характеристика, мнение пользователей"],
"locations": ["место катания: регион/название, особенности, сезон, снег"],
"prices": ["товар или услуга: цена в рублях, условия"],
"riding_tips": ["конкретный совет по технике езды, безопасности"],
"tuning": ["компонент или система: модификация, эффект от тюнинга"],
"donor_bikes": ["мотоцикл-донор: модель, почему подходит или не подходит"],
"season": ["информация о сезоне, открытии/закрытии, условиях снега"]
}
Правила:
- Только конкретные факты, никакого флуда и эмоций
- Если в чанке нет фактов по категории — пустой массив []
- Каждый факт — одна строка, максимально информативно
- Пиши на русском"""
PASS2_SYSTEM = """Ты составляешь экспертную базу знаний по сноубайкам на основе реальных обсуждений русскоязычного сообщества.
Тебе переданы факты, извлечённые из 155 000 сообщений группы "Сноубайк Россия".
Создай структурированный Markdown-документ — практическое руководство для сноубайкеров."""
def ts():
return datetime.now().strftime('%H:%M:%S')
def call_gpt4o_mini(messages_text: str, topic_name: str) -> dict:
"""Вызов GPT-4o mini для извлечения фактов из чанка."""
prompt = f"Раздел группы: «{topic_name}»\n\nСообщения:\n{messages_text}"
resp = requests.post(
'https://api.openai.com/v1/chat/completions',
headers={'Authorization': f'Bearer {OPENAI_API_KEY}',
'Content-Type': 'application/json'},
json={
'model': PASS1_MODEL,
'messages': [
{'role': 'system', 'content': PASS1_SYSTEM},
{'role': 'user', 'content': prompt}
],
'temperature': 0.1,
'max_tokens': 1000,
'response_format': {'type': 'json_object'}
},
timeout=30
)
if resp.status_code != 200:
raise RuntimeError(f'OpenAI API error {resp.status_code}: {resp.text[:200]}')
data = resp.json()
TOKEN_COUNTER['p1_input'] += data.get('usage', {}).get('prompt_tokens', 0)
TOKEN_COUNTER['p1_output'] += data.get('usage', {}).get('completion_tokens', 0)
content = data['choices'][0]['message']['content']
try:
return json.loads(content)
except json.JSONDecodeError:
return {cat: [] for cat in FACT_CATEGORIES}
def call_claude_sonnet(facts_summary: str) -> str:
"""Вызов Claude Sonnet через OpenRouter для финального синтеза."""
prompt = f"""На основе следующих фактов, извлечённых из 155 000 сообщений сообщества "Сноубайк Россия", создай подробную базу знаний.
{facts_summary}
Структура документа:
# База знаний: Сноубайки — опыт сообщества
## 1. Модели сноубайков
(популярные модели, бренды, характеристики, мнения пользователей)
## 2. Мотоциклы-доноры
(какие мотоциклы используют для переделки в сноубайк, плюсы и минусы каждого)
## 3. Частые проблемы и решения
(типичные поломки, болячки, как их устранять)
## 4. Тюнинг и модификации
(популярные доработки, что улучшает что ухудшает)
## 5. Экипировка
(что носят, что рекомендуют)
## 6. Цены и рынок
(актуальные цены на технику, запчасти, услуги)
## 7. Локации
(где катаются, особенности мест, регионы России)
## 8. Сезонность
(когда открывается/закрывается сезон, условия снега)
## 9. Техника езды
(советы по управлению, безопасности, обучению)
## 10. Электросноубайки
(электрические модели, особенности, мнения)
Пиши развёрнуто, используй конкретные цифры и факты из данных. Документ должен быть полезен как новичку, так и опытному сноубайкеру."""
resp = requests.post(
'https://openrouter.ai/api/v1/chat/completions',
headers={'Authorization': f'Bearer {OPENROUTER_API_KEY}',
'Content-Type': 'application/json'},
json={
'model': PASS2_MODEL,
'messages': [
{'role': 'system', 'content': PASS2_SYSTEM},
{'role': 'user', 'content': prompt}
],
'temperature': 0.3,
'max_tokens': 8000,
},
timeout=120
)
if resp.status_code != 200:
raise RuntimeError(f'OpenRouter API error {resp.status_code}: {resp.text[:200]}')
data = resp.json()
TOKEN_COUNTER['p2_input'] += data.get('usage', {}).get('prompt_tokens', 0)
TOKEN_COUNTER['p2_output'] += data.get('usage', {}).get('completion_tokens', 0)
return data['choices'][0]['message']['content']
def load_messages():
"""Загружает все сообщения с текстом, группируя по топику."""
meta = json.load(open(CHANNEL_DIR / 'meta.json'))
all_chunks = [] # [(topic_name, [msg_texts])]
total = 0
for tid, tname in sorted(meta['topics'].items(), key=lambda x: int(x[0])):
if tid in SKIP_TOPICS:
continue
tdir = CHANNEL_DIR / tid
if not tdir.exists():
continue
topic_msgs = []
for b in sorted(tdir.glob('batch_*.json')):
for msg in json.load(open(b)):
text = (msg.get('text') or '').strip()
if len(text) > 5: # фильтруем совсем короткие
topic_msgs.append(text)
# Разбиваем на чанки
for i in range(0, len(topic_msgs), CHUNK_SIZE):
chunk = topic_msgs[i:i + CHUNK_SIZE]
all_chunks.append((tname, chunk))
total += len(topic_msgs)
print(f" {tname}: {len(topic_msgs)} сообщений → {len(topic_msgs)//CHUNK_SIZE + 1} чанков")
print(f"Итого: {total} сообщений, {len(all_chunks)} чанков\n")
return all_chunks
def merge_facts(acc: dict, new: dict) -> dict:
"""Объединяет новые факты с накопленными."""
for cat in FACT_CATEGORIES:
items = new.get(cat, [])
if isinstance(items, list):
acc[cat].extend(items)
return acc
def load_progress() -> tuple[dict, int]:
"""Загружает прогресс из файла."""
if FACTS_FILE.exists():
data = json.load(open(FACTS_FILE))
return data['facts'], data['processed_chunks']
return {cat: [] for cat in FACT_CATEGORIES}, 0
def save_progress(facts: dict, processed: int):
"""Сохраняет прогресс."""
with open(FACTS_FILE, 'w', encoding='utf-8') as f:
json.dump({'facts': facts, 'processed_chunks': processed,
'updated_at': datetime.now().isoformat()}, f, ensure_ascii=False, indent=2)
def format_facts_for_pass2(facts: dict) -> str:
"""Форматирует накопленные факты для передачи в Пасс 2."""
labels = {
'repairs': 'РЕМОНТ И ПРОБЛЕМЫ',
'models': 'МОДЕЛИ СНОУБАЙКОВ',
'locations': 'ЛОКАЦИИ',
'prices': 'ЦЕНЫ И РЫНОК',
'riding_tips': 'ТЕХНИКА ЕЗДЫ',
'tuning': 'ТЮНИНГ И МОДИФИКАЦИИ',
'donor_bikes': 'МОТОЦИКЛЫ-ДОНОРЫ',
'season': 'СЕЗОННОСТЬ',
}
lines = []
for cat, label in labels.items():
items = facts.get(cat, [])
if items:
# Фильтруем только строки, дедупликация
str_items = [i for i in items if isinstance(i, str)]
unique = list(dict.fromkeys(str_items))[:300] # макс 300 фактов на категорию
lines.append(f"\n=== {label} ({len(unique)} фактов) ===")
for item in unique:
lines.append(f"{item}")
return '\n'.join(lines)
def pass1(all_chunks: list, facts: dict, start_from: int) -> dict:
"""Пасс 1: извлечение фактов через GPT-4o mini."""
total = len(all_chunks)
errors = 0
for i, (topic_name, chunk) in enumerate(all_chunks):
if i < start_from:
continue
chunk_text = '\n---\n'.join(chunk)
pct = (i + 1) / total * 100
try:
result = call_gpt4o_mini(chunk_text, topic_name)
facts = merge_facts(facts, result)
new_facts = sum(len(v) for v in result.values() if isinstance(v, list))
print(f"[{ts()}] Чанк {i+1}/{total} ({pct:.0f}%) [{topic_name}]: +{new_facts} фактов")
except Exception as e:
errors += 1
print(f"[{ts()}] ⚠ Чанк {i+1} ошибка: {e}")
if errors > 10:
print("Слишком много ошибок, останавливаемся")
break
# Сохраняем прогресс каждые 10 чанков
if (i + 1) % 10 == 0:
save_progress(facts, i + 1)
# Небольшая пауза чтобы не превышать rate limit
time.sleep(0.3)
save_progress(facts, len(all_chunks))
return facts
def pass2(facts: dict) -> str:
"""Пасс 2: синтез knowledge_base.md через Claude Sonnet."""
facts_text = format_facts_for_pass2(facts)
total_facts = sum(len(v) for v in facts.values() if isinstance(v, list))
print(f"[{ts()}] Пасс 2: передаём {total_facts} фактов в Claude Sonnet...")
return call_claude_sonnet(facts_text)
# Счётчики токенов для подсчёта стоимости
TOKEN_COUNTER = {'p1_input': 0, 'p1_output': 0, 'p2_input': 0, 'p2_output': 0}
PRICES = {
'gpt-4o-mini': {'input': 0.15, 'output': 0.60}, # $ per 1M tokens
'claude-sonnet':{'input': 3.00, 'output': 15.00},
}
def calc_cost():
p1 = PRICES['gpt-4o-mini']
p2 = PRICES['claude-sonnet']
cost_p1 = (TOKEN_COUNTER['p1_input'] / 1_000_000 * p1['input'] +
TOKEN_COUNTER['p1_output'] / 1_000_000 * p1['output'])
cost_p2 = (TOKEN_COUNTER['p2_input'] / 1_000_000 * p2['input'] +
TOKEN_COUNTER['p2_output'] / 1_000_000 * p2['output'])
return cost_p1, cost_p2, cost_p1 + cost_p2
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--pass1-only', action='store_true', help='Только пасс 1')
parser.add_argument('--pass2-only', action='store_true', help='Только пасс 2 (из готовых фактов)')
parser.add_argument('--reset', action='store_true', help='Начать заново, игнорировать прогресс')
args = parser.parse_args()
print(f"[{ts()}] === Анализ @snowbikerussia ===\n")
if args.pass2_only:
if not FACTS_FILE.exists():
print("Нет файла фактов, сначала запусти пасс 1")
return
facts, _ = load_progress()
total_facts = sum(len(v) for v in facts.values() if isinstance(v, list))
print(f"[{ts()}] Загружено {total_facts} фактов из {FACTS_FILE}")
else:
# Пасс 1
if args.reset and FACTS_FILE.exists():
FACTS_FILE.unlink()
print(f"[{ts()}] Прогресс сброшен")
facts, start_from = load_progress()
if start_from > 0:
total_facts = sum(len(v) for v in facts.values() if isinstance(v, list))
print(f"[{ts()}] Продолжаем с чанка #{start_from+1} ({total_facts} фактов уже собрано)")
print(f"[{ts()}] Загружаю сообщения...")
all_chunks = load_messages()
print(f"[{ts()}] === ПАСС 1: GPT-4o mini ===")
facts = pass1(all_chunks, facts, start_from)
total_facts = sum(len(v) for v in facts.values() if isinstance(v, list))
print(f"\n[{ts()}] Пасс 1 завершён. Извлечено {total_facts} фактов:")
for cat in FACT_CATEGORIES:
print(f" {cat}: {len(facts.get(cat, []))}")
if args.pass1_only:
print(f"\nФакты сохранены в {FACTS_FILE}")
return
# Пасс 2
print(f"\n[{ts()}] === ПАСС 2: Claude Sonnet ===")
kb_content = pass2(facts)
# Сохраняем результат
header = f"<!-- Сгенерировано: {datetime.now().strftime('%Y-%m-%d %H:%M')} UTC -->\n"
header += f"<!-- Источник: @snowbikerussia, {sum(len(v) for v in facts.values() if isinstance(v, list))} фактов -->\n\n"
with open(KB_FILE, 'w', encoding='utf-8') as f:
f.write(header + kb_content)
cost_p1, cost_p2, total_cost = calc_cost()
print(f"\n[{ts()}] ✅ База знаний сохранена: {KB_FILE}")
print(f"[{ts()}] Размер: {KB_FILE.stat().st_size / 1024:.1f} КБ")
print(f"\n[{ts()}] 💰 Стоимость анализа:")
print(f" Пасс 1 (GPT-4o mini): ${cost_p1:.3f} ({TOKEN_COUNTER['p1_input']:,} in / {TOKEN_COUNTER['p1_output']:,} out tokens)")
print(f" Пасс 2 (Claude Sonnet): ${cost_p2:.3f} ({TOKEN_COUNTER['p2_input']:,} in / {TOKEN_COUNTER['p2_output']:,} out tokens)")
print(f" ИТОГО: ${total_cost:.3f}")
if __name__ == '__main__':
main()