| """Knowledge base module for ORTOS Telegram Bot. |
| |
| Loads knowledge_base.json, indexes sections, and searches by keyword. |
| """ |
|
|
| import json |
| import re |
| from collections import Counter |
| from pydantic import BaseModel |
| from nltk.stem.snowball import SnowballStemmer |
|
|
|
|
| stemmer = SnowballStemmer('russian') |
|
|
|
|
| class KnowledgeItem(BaseModel): |
| id: str |
| title: str |
| content: str |
|
|
|
|
| def _extract_words(text: str) -> list[str]: |
| words = re.findall(r'[а-яё]{3,}', text.lower()) |
| return [stemmer.stem(w) for w in words if len(w) >= 3] |
|
|
|
|
| STOPWORDS = { |
| 'это', 'который', 'которая', 'которое', 'которые', 'которого', |
| 'которой', 'которому', 'которым', 'которых', 'который', |
| 'в', 'на', 'с', 'по', 'к', 'из', 'для', 'у', 'о', |
| 'не', 'но', 'и', 'или', 'также', 'только', 'еще', |
| 'при', 'как', 'чем', 'что', 'такой', 'такая', 'такое', |
| 'где', 'зачем', 'когда', 'почему', 'кто', 'что', |
| 'от', 'до', 'за', 'без', 'между', 'через', |
| } |
|
|
|
|
| def _load_keywords(paths: list[str] | str) -> dict[str, dict[str, float]]: |
| """Load and compute TF-IDF for all sections across multiple files.""" |
| if isinstance(paths, str): |
| paths = [paths] |
| all_docs = [] |
| sections_data = [] |
| |
| for path in paths: |
| with open(path, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| for name, sec in data['sections'].items(): |
| key = f"{path}:{name}" |
| words = _extract_words(sec['title'] + '\n\n' + sec['content']) |
| all_docs.append(words) |
| sections_data.append((key, sec)) |
| |
| tfidf = {} |
| |
| for key, sec in sections_data: |
| words = _extract_words(sec['title'] + '\n\n' + sec['content']) |
| words = [w for w in words if w not in STOPWORDS] |
| |
| word_counts = Counter(words) |
| total_words = len(words) or 1 |
| |
| tfidf[key] = { |
| 'title': sec['title'], |
| 'content': sec['content'], |
| 'tfidf': {w: count / total_words * (1 + sum(1 for doc in all_docs if w in doc)) |
| for w, count in word_counts.items()}, |
| } |
| |
| return tfidf |
|
|
|
|
| def load_knowledge_base(paths: list[str] | str) -> list[KnowledgeItem]: |
| """Load sections from multiple knowledge base files.""" |
| if isinstance(paths, str): |
| paths = [paths] |
| items = [] |
| for path in paths: |
| with open(path, 'r', encoding='utf-8') as f: |
| data = json.load(f) |
| for name, sec in data['sections'].items(): |
| items.append(KnowledgeItem(id=f"{path}:{name}", title=sec['title'], content=sec['content'])) |
| return items |
|
|
|
|
| def search(query: str, top_k: int = 2, items: list[KnowledgeItem] = [], |
| tfidf: dict[str, dict[str, float]] = {}) -> list[KnowledgeItem]: |
| """Search by TF-IDF. Returns top-k items with score > 0.""" |
| query_words = set(_extract_words(query)) |
| query_words -= STOPWORDS |
| |
| if not tfidf: |
| return [] |
| |
| results = [] |
| for name, data in tfidf.items(): |
| score = sum(data['tfidf'].get(w, 0) for w in query_words) |
| title_stems = set(_extract_words(data['title'])) |
| title_bonus = sum(2.0 for w in query_words if w in title_stems) |
| score += title_bonus |
| if score > 0: |
| results.append((data['content'], score)) |
| |
| results.sort(key=lambda x: x[1], reverse=True) |
| |
| |
| matched_items = [] |
| for content, _ in results[:top_k]: |
| for item in items: |
| if item.content == content: |
| matched_items.append(item) |
| break |
| |
| return matched_items |
|
|
|
|
| def reload_knowledge(paths: list[str] | str) -> tuple[list[KnowledgeItem], dict[str, dict[str, float]]]: |
| """Reload and recompute keywords from one or multiple files.""" |
| if isinstance(paths, str): |
| paths = [paths] |
| items = load_knowledge_base(paths) |
| tfidf = _load_keywords(paths) |
| return items, tfidf |
|
|