#!/usr/bin/env python3 """ Гибридный поиск: Meilisearch + ChromaDB + LLM суммаризация. """ import json import os import re import sys import time from pathlib import Path from typing import Optional import requests as _requests # Сессия без прокси для внутренних сервисов (Meilisearch/ChromaDB на localhost) _internal_session = _requests.Session() _internal_session.trust_env = False # Загружаем .env def load_env(): env_file = Path.home() / ".openclaw" / ".env" if env_file.exists(): with open(env_file) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, val = line.partition("=") os.environ.setdefault(key.strip(), val.strip()) load_env() MEILI_URL = "http://127.0.0.1:7700" CHROMA_PATH = str(Path(__file__).parent.parent / "data" / "chromadb") COLLECTION_NAME = "snowbike_embeddings" INDEX_NAME = "snowbike_messages" MODEL_NAME = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY", "") LLM_MODEL = "anthropic/claude-sonnet-4-5" # Порог релевантности для ChromaDB (меньше = лучше совпадение) CHROMA_DISTANCE_THRESHOLD = 1.2 # === СЕЗОННЫЙ ФИЛЬТР === # Маппинг ключевых слов → месяцы (включая соседние для контекста) SEASON_KEYWORDS = { # Месяцы "январ": [12, 1, 2], "феврал": [1, 2, 3], "март": [2, 3, 4], "апрел": [3, 4, 5], "май": [4, 5, 6], "июн": [5, 6, 7], "июл": [6, 7, 8], "август": [7, 8, 9], "сентябр": [8, 9, 10], "октябр": [9, 10, 11], "ноябр": [10, 11, 12], "декабр": [11, 12, 1], # Сезоны "зим": [12, 1, 2], "весн": [3, 4, 5], "лет": [6, 7, 8], "осен": [9, 10, 11], # Сезон катания "сезон": [11, 12, 1, 2, 3, 4], # типичный сноубайк-сезон "межсезон": [5, 6, 7, 8, 9, 10], } def detect_season_months(query: str) -> Optional[list]: """ Определяет месяцы из запроса. Возвращает список номеров месяцев (1-12) или None. """ query_lower = query.lower() for keyword, months in SEASON_KEYWORDS.items(): if keyword in query_lower: return months return None SYSTEM_PROMPT = """Ты — помощник по сноубайкам. Отвечаешь на вопросы на основе сообщений из Telegram-группы «Сноубайк Россия». Правила: 1. Отвечай только на русском языке 2. Используй только информацию из предоставленных сообщений 3. Если информации недостаточно — честно скажи об этом, перечисли что было упомянуто 4. Всегда указывай конкретику: ДАТЫ (в формате «в апреле 2024», «в январе 2023» и т.д.), имена (если есть), цифры 5. Агрегируй мнения: если несколько человек говорят одно и то же — обобщи 6. Будь конкретен: перечисли проблемы/решения по пунктам 7. При ответе на сезонные вопросы — обязательно указывай из каких месяцев/лет взяты данные""" USER_PROMPT_TEMPLATE = """Вопрос: {question} Найденные сообщения из чата «Сноубайк Россия»: {context} Дай связный ответ на вопрос. Если нашлись конкретные факты — перечисли их. Обязательно упоминай даты сообщений (месяц и год) когда это важно для контекста.""" # Глобальные клиенты (инициализируются лениво) _chroma_collection = None _embed_model = None def get_chroma_collection(): global _chroma_collection if _chroma_collection is None: import chromadb client = chromadb.PersistentClient(path=CHROMA_PATH) _chroma_collection = client.get_collection(COLLECTION_NAME) return _chroma_collection def get_embed_model(): global _embed_model if _embed_model is None: from sentence_transformers import SentenceTransformer _embed_model = SentenceTransformer(MODEL_NAME) return _embed_model def search_meilisearch(query: str, topic_ids: Optional[list] = None, limit: int = 20, season_months: Optional[list] = None) -> list: """Полнотекстовый поиск в Meilisearch через HTTP.""" try: fetch_limit = limit * 6 if season_months else limit params = {"q": query, "limit": fetch_limit} if topic_ids: filter_str = " OR ".join(f"topic_id = {t}" for t in topic_ids) params["filter"] = filter_str resp = _internal_session.post( f"{MEILI_URL}/indexes/{INDEX_NAME}/search", json=params, timeout=10, ) resp.raise_for_status() hits = resp.json().get("hits", []) if season_months: # Разделяем на "в сезон" и "вне сезона", возвращаем сначала сезонные in_season = [] out_season = [] for hit in hits: date_str = hit.get("date", "") try: month = int(date_str[5:7]) if len(date_str) >= 7 else 0 except (ValueError, IndexError): month = 0 if month in season_months: in_season.append(hit) else: out_season.append(hit) hits = (in_season + out_season)[:limit] return hits[:limit] except Exception as e: print(f"Meilisearch ошибка: {e}", file=sys.stderr) return [] def search_chromadb(query: str, topic_ids: Optional[list] = None, limit: int = 20, season_months: Optional[list] = None) -> list: """Семантический поиск в ChromaDB с фильтрацией по дистанции.""" try: model = get_embed_model() collection = get_chroma_collection() embedding = model.encode(query).tolist() # Строим where-фильтр conditions = [] if topic_ids: if len(topic_ids) == 1: conditions.append({"topic_id": topic_ids[0]}) else: conditions.append({"$or": [{"topic_id": t} for t in topic_ids]}) if season_months: # Используем поле month если доступно (после переиндексации) # Для старых записей без month — пробуем с запасом и пост-фильтрацией has_month_field = _check_month_field(collection) if has_month_field: if len(season_months) == 1: conditions.append({"month": season_months[0]}) else: conditions.append({"$or": [{"month": m} for m in season_months]}) where = None if len(conditions) == 1: where = conditions[0] elif len(conditions) > 1: where = {"$and": conditions} fetch_n = limit if not (season_months and not _check_month_field(collection)) else limit * 4 kwargs = { "query_embeddings": [embedding], "n_results": min(fetch_n, collection.count()), "include": ["documents", "metadatas", "distances"], } if where: kwargs["where"] = where result = collection.query(**kwargs) hits = [] ids = result.get("ids", [[]])[0] docs = result.get("documents", [[]])[0] metas = result.get("metadatas", [[]])[0] dists = result.get("distances", [[]])[0] for msg_id, doc, meta, dist in zip(ids, docs, metas, dists): if dist > CHROMA_DISTANCE_THRESHOLD: continue # Пост-фильтр по месяцу если нет поля month if season_months and not _check_month_field(collection): date_str = meta.get("date", "") try: month = int(date_str[5:7]) if len(date_str) >= 7 else 0 except (ValueError, IndexError): month = 0 if month not in season_months: continue hits.append({ "id": int(msg_id), "text": doc, "topic_id": meta.get("topic_id"), "topic_title": meta.get("topic_title", ""), "date": meta.get("date", ""), "from_id": meta.get("from_id", ""), "_chroma_distance": dist, }) return hits[:limit] except Exception as e: print(f"ChromaDB ошибка: {e}", file=sys.stderr) return [] _month_field_cache = None def _check_month_field(collection) -> bool: """Проверяем есть ли поле month в коллекции (кэшируем результат).""" global _month_field_cache if _month_field_cache is not None: return _month_field_cache try: sample = collection.get(limit=1, include=["metadatas"]) metas = sample.get("metadatas", []) if metas and "month" in metas[0]: _month_field_cache = True else: _month_field_cache = False except Exception: _month_field_cache = False return _month_field_cache def merge_results(meili_hits: list, chroma_hits: list, limit: int = 15) -> list: """Объединяем и дедуплицируем результаты.""" seen_ids = set() merged = [] # Meilisearch результаты — полнотекстовые, высокий приоритет for hit in meili_hits: msg_id = hit.get("id") if msg_id and msg_id not in seen_ids: hit["_source"] = "meilisearch" merged.append(hit) seen_ids.add(msg_id) # ChromaDB результаты — семантические, дополняем for hit in chroma_hits: msg_id = hit.get("id") if msg_id and msg_id not in seen_ids: hit["_source"] = "chromadb" merged.append(hit) seen_ids.add(msg_id) return merged[:limit] def format_context(messages: list) -> str: """Форматируем контекст для LLM с явными датами.""" lines = [] for i, msg in enumerate(messages, 1): date_full = msg.get("date", "") # Форматируем дату как "15 апреля 2024" date_display = _format_date_russian(date_full) topic = msg.get("topic_title", "?") author = msg.get("from_id", "?") text = msg.get("text", "") msg_id = msg.get("id", "?") source = msg.get("_source", "") lines.append(f"[{i}] ID:{msg_id} | Дата: {date_display} | Топик: {topic} | автор:{author} | источник:{source}") lines.append(f" {text}") lines.append("") return "\n".join(lines) MONTH_NAMES_RU = { 1: "января", 2: "февраля", 3: "марта", 4: "апреля", 5: "мая", 6: "июня", 7: "июля", 8: "августа", 9: "сентября", 10: "октября", 11: "ноября", 12: "декабря", } def _format_date_russian(date_str: str) -> str: """Преобразует ISO дату в читаемый русский формат.""" try: # "2024-04-15T..." year = int(date_str[:4]) month = int(date_str[5:7]) day = int(date_str[8:10]) return f"{day} {MONTH_NAMES_RU[month]} {year}" except Exception: return date_str[:10] if date_str else "?" def ask_llm(question: str, context: str) -> str: """Задаём вопрос LLM через OpenRouter.""" if not OPENROUTER_KEY: return "ОШИБКА: OPENROUTER_API_KEY не задан в ~/.openclaw/.env" user_msg = USER_PROMPT_TEMPLATE.format(question=question, context=context) response = _requests.post( "https://openrouter.ai/api/v1/chat/completions", headers={ "Authorization": f"Bearer {OPENROUTER_KEY}", "Content-Type": "application/json", "HTTP-Referer": "https://snowbike-rag.local", "X-Title": "Snowbike RAG", }, json={ "model": LLM_MODEL, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_msg}, ], "max_tokens": 1500, "temperature": 0.3, }, timeout=30, ) response.raise_for_status() data = response.json() return data["choices"][0]["message"]["content"] def search(query: str, topic_ids: Optional[list] = None, limit: int = 15) -> dict: """ Главная функция гибридного поиска. Returns: dict с полями: query, answer, sources, count, time_ms """ start_time = time.time() # Определяем сезонные месяцы season_months = detect_season_months(query) if season_months: print(f"Сезонный запрос, фильтр по месяцам: {season_months}", file=sys.stderr) # Поиск meili_hits = search_meilisearch(query, topic_ids, limit=20, season_months=season_months) chroma_hits = search_chromadb(query, topic_ids, limit=20, season_months=season_months) print(f"Meilisearch hits: {len(meili_hits)}, ChromaDB hits: {len(chroma_hits)}", file=sys.stderr) merged = merge_results(meili_hits, chroma_hits, limit=limit) if not merged: return { "query": query, "answer": "К сожалению, по вашему запросу ничего не найдено в базе сообщений.", "sources": [], "count": 0, "time_ms": int((time.time() - start_time) * 1000), } # Формируем контекст для LLM context = format_context(merged) # LLM ответ answer = ask_llm(query, context) # Источники sources = [ { "id": msg.get("id"), "date": msg.get("date", "")[:10], "date_display": _format_date_russian(msg.get("date", "")), "topic": msg.get("topic_title", ""), "author": str(msg.get("from_id", "")), "text_preview": msg.get("text", "")[:120], } for msg in merged ] elapsed_ms = int((time.time() - start_time) * 1000) return { "query": query, "answer": answer, "sources": sources, "count": len(merged), "time_ms": elapsed_ms, "season_months": season_months, } if __name__ == "__main__": query = sys.argv[1] if len(sys.argv) > 1 else "проблемы ремень трикстер" print(f"Запрос: {query}\n") result = search(query) print(f"Найдено источников: {result['count']}") print(f"Время: {result['time_ms']} мс") if result.get("season_months"): print(f"Сезонный фильтр (месяцы): {result['season_months']}") print("\n=== ОТВЕТ ===") print(result["answer"]) print("\n=== ИСТОЧНИКИ ===") for s in result["sources"][:5]: print(f" [{s['id']}] {s['date_display']} | {s['topic']} | {s['text_preview'][:100]}...")