montana/Русский/Гиппокамп/Архив/hippocampus_full.py

1157 lines
46 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""
Внешний Гиппокамп Montana ПОЛНАЯ РЕАЛИЗАЦИЯ
==============================================
Фаза 1: Production
- Детектор новизны
- Pattern separation
- Консолидация
Фаза 2: Улучшения
- RAG интеграция (семантический поиск)
- Визуализация плотности кодирования
- Экспорт в Markdown/PDF
- Музыкальные якоря
- Геолокация
Фаза 3: Масштабирование
- Multi-user память
- Shared memories
- Cross-reference между координатами
Использование:
python hippocampus_full.py --help
"""
import json
import hashlib
import os
from dataclasses import dataclass, field, asdict
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional, List, Dict, Set
from collections import defaultdict
import argparse
import re
# Опциональные зависимости
try:
import chromadb
from chromadb.config import Settings
HAS_CHROMADB = True
except ImportError:
HAS_CHROMADB = False
try:
from sentence_transformers import SentenceTransformer
HAS_EMBEDDINGS = True
except ImportError:
HAS_EMBEDDINGS = False
try:
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
HAS_MATPLOTLIB = True
except ImportError:
HAS_MATPLOTLIB = False
try:
from fpdf import FPDF
HAS_FPDF = True
except ImportError:
HAS_FPDF = False
# ═══════════════════════════════════════════════════════════════════════════════
# МОДЕЛИ ДАННЫХ
# ═══════════════════════════════════════════════════════════════════════════════
@dataclass
class Coordinate:
"""
Координата в 4D пространстве памяти
4 якоря:
1. Дигитальный (thought) текст мысли
2. Временной (timestamp) UTC метка
3. Пространственный (location) GPS
4. Аудиальный (music) музыка момента
"""
id: str # Уникальный ID (hash)
user_id: int # Кто
username: str # @username
timestamp: str # Когда (UTC ISO)
thought: str # Что (текст)
lang: str = "ru" # Язык
# Дополнительные якоря
location: Optional[str] = None # GPS: "55.7558,37.6173"
location_name: Optional[str] = None # "Москва, Красная площадь"
music_track: Optional[str] = None # "Hans Zimmer - Time"
music_id: Optional[str] = None # Spotify/Shazam ID
image_url: Optional[str] = None # Визуальный якорь
# Метаданные
tags: List[str] = field(default_factory=list) # #теги
references: List[str] = field(default_factory=list) # Ссылки на другие координаты
shared_with: List[int] = field(default_factory=list) # Расшарено с user_ids
is_public: bool = False # Публичная память
def __post_init__(self):
if not self.id:
self.id = self._generate_id()
def _generate_id(self) -> str:
"""Генерация уникального ID на основе содержимого"""
content = f"{self.user_id}:{self.timestamp}:{self.thought}"
return hashlib.sha256(content.encode()).hexdigest()[:16]
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, data: dict) -> 'Coordinate':
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
@dataclass
class UserMemory:
"""Профиль памяти пользователя"""
user_id: int
username: str
total_thoughts: int = 0
first_thought: Optional[str] = None
last_thought: Optional[str] = None
density_per_day: float = 0.0
top_tags: List[str] = field(default_factory=list)
connected_users: List[int] = field(default_factory=list) # Через shared memories
# ═══════════════════════════════════════════════════════════════════════════════
# ВНЕШНИЙ ГИППОКАМП
# ═══════════════════════════════════════════════════════════════════════════════
class ExternalHippocampus:
"""
Внешний Гиппокамп Montana Полная реализация
Эмулирует биологическую память с критическим улучшением:
ПЕРЕЖИВАЕТ СМЕРТЬ НОСИТЕЛЯ
"""
def __init__(self, data_dir: Optional[str] = None):
self.data_dir = Path(data_dir) if data_dir else Path(__file__).parent / "data"
self.data_dir.mkdir(parents=True, exist_ok=True)
# Файлы данных
self.stream_file = self.data_dir / "stream.jsonl"
self.index_file = self.data_dir / "index.json"
self.users_file = self.data_dir / "users.json"
self.shared_file = self.data_dir / "shared.jsonl"
self.references_file = self.data_dir / "references.json"
# RAG (если доступен)
self.chroma_client = None
self.collection = None
self.embedder = None
if HAS_CHROMADB:
self._init_rag()
# Кэши
self._index_cache: Dict[str, Coordinate] = {}
self._user_cache: Dict[int, UserMemory] = {}
self._references_cache: Dict[str, Set[str]] = defaultdict(set)
# Загружаем индексы
self._load_index()
self._load_references()
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 1: ЯДРО
# ═══════════════════════════════════════════════════════════════════════════
def is_raw_thought(self, text: str) -> bool:
"""
Детектор новизны определяет мысль vs вопрос/команда
"""
text = text.strip()
if len(text) > 500 or len(text) < 5:
return False
if text.endswith("?"):
return False
# Вопросительные слова
question_words = [
'что', 'как', 'почему', 'зачем', 'когда', 'где', 'кто',
'what', 'how', 'why', 'when', 'where', 'who',
'什么', '怎么', '为什么', '何时', '哪里', ''
]
# Команды
commands = [
'покажи', 'расскажи', 'объясни', 'помоги', 'найди',
'show', 'tell', 'explain', 'help', 'find',
'/start', '/help', '/stream', '/export', '/search'
]
text_lower = text.lower()
first_word = text_lower.split()[0] if text_lower.split() else ""
if first_word in question_words or first_word in commands:
return False
return True
def save(
self,
user_id: int,
username: str,
thought: str,
lang: str = "ru",
location: Optional[str] = None,
location_name: Optional[str] = None,
music_track: Optional[str] = None,
music_id: Optional[str] = None,
tags: Optional[List[str]] = None,
is_public: bool = False
) -> Coordinate:
"""
Pattern Separation сохранить мысль как координату
"""
# Автоизвлечение тегов
if tags is None:
tags = self._extract_tags(thought)
# Создаём координату
coord = Coordinate(
id="", # Будет сгенерирован
user_id=user_id,
username=username,
timestamp=datetime.utcnow().isoformat() + "Z",
thought=thought,
lang=lang,
location=location,
location_name=location_name,
music_track=music_track,
music_id=music_id,
tags=tags,
is_public=is_public
)
# Автоматический cross-reference
refs = self._find_references(coord)
coord.references = refs
# Сохраняем в stream (append-only)
with open(self.stream_file, "a", encoding="utf-8") as f:
f.write(json.dumps(coord.to_dict(), ensure_ascii=False) + "\n")
# Обновляем индекс
self._index_cache[coord.id] = coord
self._save_index()
# Обновляем references
for ref_id in refs:
self._references_cache[ref_id].add(coord.id)
self._save_references()
# Добавляем в RAG
if self.collection:
self._add_to_rag(coord)
return coord
def get(self, coord_id: str) -> Optional[Coordinate]:
"""Получить координату по ID"""
return self._index_cache.get(coord_id)
def get_user_stream(self, user_id: int, limit: int = 100) -> List[Coordinate]:
"""Получить мысли пользователя"""
coords = [c for c in self._index_cache.values() if c.user_id == user_id]
coords.sort(key=lambda x: x.timestamp)
return coords[-limit:]
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 2: RAG ПОИСК
# ═══════════════════════════════════════════════════════════════════════════
def _init_rag(self):
"""Инициализация RAG системы"""
try:
self.chroma_client = chromadb.Client(Settings(
chroma_db_impl="duckdb+parquet",
persist_directory=str(self.data_dir / "chroma"),
anonymized_telemetry=False
))
self.collection = self.chroma_client.get_or_create_collection(
name="hippocampus",
metadata={"hnsw:space": "cosine"}
)
if HAS_EMBEDDINGS:
self.embedder = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
except Exception as e:
print(f"RAG init failed: {e}")
self.collection = None
def _add_to_rag(self, coord: Coordinate):
"""Добавить координату в RAG"""
if not self.collection:
return
try:
# Создаём embedding
if self.embedder:
embedding = self.embedder.encode(coord.thought).tolist()
self.collection.add(
ids=[coord.id],
embeddings=[embedding],
documents=[coord.thought],
metadatas=[{
"user_id": coord.user_id,
"timestamp": coord.timestamp,
"tags": ",".join(coord.tags)
}]
)
else:
# Без embeddings — просто документы
self.collection.add(
ids=[coord.id],
documents=[coord.thought],
metadatas=[{
"user_id": coord.user_id,
"timestamp": coord.timestamp,
"tags": ",".join(coord.tags)
}]
)
except Exception as e:
print(f"RAG add failed: {e}")
def search(
self,
query: str,
user_id: Optional[int] = None,
limit: int = 10,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
tags: Optional[List[str]] = None
) -> List[Coordinate]:
"""
Семантический поиск по памяти
"""
# Пробуем RAG поиск
if self.collection and self.embedder:
try:
where_filter = {}
if user_id:
where_filter["user_id"] = user_id
query_embedding = self.embedder.encode(query).tolist()
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=limit,
where=where_filter if where_filter else None
)
coord_ids = results['ids'][0] if results['ids'] else []
coords = [self._index_cache[cid] for cid in coord_ids if cid in self._index_cache]
# Дополнительная фильтрация
if from_date:
coords = [c for c in coords if c.timestamp[:10] >= from_date]
if to_date:
coords = [c for c in coords if c.timestamp[:10] <= to_date]
if tags:
coords = [c for c in coords if any(t in c.tags for t in tags)]
return coords
except Exception as e:
print(f"RAG search failed: {e}")
# Fallback: простой текстовый поиск
return self._simple_search(query, user_id, limit, from_date, to_date, tags)
def _simple_search(
self,
query: str,
user_id: Optional[int] = None,
limit: int = 10,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
tags: Optional[List[str]] = None
) -> List[Coordinate]:
"""Простой текстовый поиск"""
query_lower = query.lower()
results = []
for coord in self._index_cache.values():
if user_id and coord.user_id != user_id:
continue
if from_date and coord.timestamp[:10] < from_date:
continue
if to_date and coord.timestamp[:10] > to_date:
continue
if tags and not any(t in coord.tags for t in tags):
continue
if query_lower in coord.thought.lower():
results.append(coord)
return results[:limit]
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 2: ВИЗУАЛИЗАЦИЯ
# ═══════════════════════════════════════════════════════════════════════════
def plot_density(
self,
user_id: Optional[int] = None,
period: str = "month",
output_path: Optional[str] = None
) -> Optional[str]:
"""
Визуализация плотности кодирования памяти
Args:
user_id: Фильтр по пользователю
period: day, week, month, year, all
output_path: Путь для сохранения PNG
"""
if not HAS_MATPLOTLIB:
return "matplotlib не установлен. pip install matplotlib"
coords = list(self._index_cache.values())
if user_id:
coords = [c for c in coords if c.user_id == user_id]
if not coords:
return "Нет данных для визуализации"
# Группируем по дням
daily_counts = defaultdict(int)
for coord in coords:
date = coord.timestamp[:10]
daily_counts[date] += 1
# Сортируем
dates = sorted(daily_counts.keys())
counts = [daily_counts[d] for d in dates]
# Фильтруем по периоду
now = datetime.now()
if period == "week":
cutoff = (now - timedelta(days=7)).strftime('%Y-%m-%d')
elif period == "month":
cutoff = (now - timedelta(days=30)).strftime('%Y-%m-%d')
elif period == "year":
cutoff = (now - timedelta(days=365)).strftime('%Y-%m-%d')
else:
cutoff = "1970-01-01"
filtered_dates = [d for d in dates if d >= cutoff]
filtered_counts = [daily_counts[d] for d in filtered_dates]
if not filtered_dates:
return "Нет данных за выбранный период"
# Строим график
fig, ax = plt.subplots(figsize=(12, 6))
x_dates = [datetime.strptime(d, '%Y-%m-%d') for d in filtered_dates]
ax.bar(x_dates, filtered_counts, color='#4A90D9', alpha=0.8)
ax.plot(x_dates, filtered_counts, color='#2C5282', linewidth=2, marker='o', markersize=4)
ax.set_xlabel('Дата', fontsize=12)
ax.set_ylabel('Количество мыслей', fontsize=12)
ax.set_title(f'Плотность кодирования памяти Montana\nПериод: {period}', fontsize=14)
ax.xaxis.set_major_formatter(mdates.DateFormatter('%d.%m'))
ax.xaxis.set_major_locator(mdates.AutoDateLocator())
plt.xticks(rotation=45)
# Добавляем статистику
total = sum(filtered_counts)
avg = total / len(filtered_counts) if filtered_counts else 0
max_val = max(filtered_counts) if filtered_counts else 0
stats_text = f'Всего: {total} | Среднее: {avg:.1f}/день | Макс: {max_val}'
ax.text(0.02, 0.98, stats_text, transform=ax.transAxes, fontsize=10,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
plt.tight_layout()
# Сохраняем или показываем
if output_path:
plt.savefig(output_path, dpi=150, bbox_inches='tight')
plt.close()
return output_path
else:
output_path = self.data_dir / f"density_{period}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
plt.savefig(output_path, dpi=150, bbox_inches='tight')
plt.close()
return str(output_path)
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 2: ЭКСПОРТ
# ═══════════════════════════════════════════════════════════════════════════
def export_markdown(
self,
user_id: Optional[int] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
include_metadata: bool = True
) -> str:
"""Экспорт в Markdown"""
coords = self._filter_coords(user_id, from_date, to_date)
if not coords:
return "# Поток мыслей Montana\n\nПусто."
username = coords[0].username if coords else "unknown"
lines = [
f"# Поток мыслей @{username}",
"",
f"**Всего мыслей:** {len(coords)}",
f"**Период:** {coords[0].timestamp[:10]}{coords[-1].timestamp[:10]}",
"",
"---",
""
]
current_date = None
for coord in coords:
date = coord.timestamp[:10]
time = coord.timestamp[11:16]
if date != current_date:
current_date = date
lines.append(f"## {date}")
lines.append("")
lines.append(f"### [{time}] {coord.thought[:50]}...")
lines.append("")
lines.append(f"> {coord.thought}")
lines.append("")
if include_metadata:
if coord.tags:
lines.append(f"**Теги:** {', '.join(coord.tags)}")
if coord.location_name:
lines.append(f"**Место:** {coord.location_name}")
if coord.music_track:
lines.append(f"**Музыка:** {coord.music_track}")
if coord.references:
lines.append(f"**Связи:** {len(coord.references)} координат")
lines.append("")
lines.extend([
"---",
"",
f"*Экспорт: {datetime.now().strftime('%Y-%m-%d %H:%M')}*",
"",
"金元Ɉ Montana — Внешний гиппокамп"
])
return "\n".join(lines)
def export_pdf(
self,
user_id: Optional[int] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
output_path: Optional[str] = None
) -> Optional[str]:
"""Экспорт в PDF"""
if not HAS_FPDF:
return None
coords = self._filter_coords(user_id, from_date, to_date)
if not coords:
return None
username = coords[0].username if coords else "unknown"
pdf = FPDF()
pdf.add_page()
# Заголовок
pdf.set_font('Arial', 'B', 16)
pdf.cell(0, 10, f'Поток мыслей @{username}', ln=True, align='C')
pdf.set_font('Arial', '', 10)
pdf.cell(0, 10, f'Всего: {len(coords)} мыслей', ln=True, align='C')
pdf.ln(10)
# Мысли
pdf.set_font('Arial', '', 11)
current_date = None
for coord in coords:
date = coord.timestamp[:10]
time = coord.timestamp[11:16]
if date != current_date:
current_date = date
pdf.set_font('Arial', 'B', 12)
pdf.cell(0, 10, date, ln=True)
pdf.set_font('Arial', '', 11)
# Используем ASCII-совместимый текст
thought_ascii = coord.thought.encode('latin-1', 'replace').decode('latin-1')
pdf.multi_cell(0, 8, f"[{time}] {thought_ascii}")
pdf.ln(2)
# Сохраняем
if not output_path:
output_path = self.data_dir / f"thoughts_{username}_{datetime.now().strftime('%Y%m%d')}.pdf"
pdf.output(str(output_path))
return str(output_path)
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 2: МУЗЫКАЛЬНЫЕ ЯКОРЯ
# ═══════════════════════════════════════════════════════════════════════════
def add_music_anchor(
self,
coord_id: str,
track_name: str,
track_id: Optional[str] = None,
source: str = "manual" # manual, spotify, shazam
) -> bool:
"""
Добавить музыкальный якорь к координатемузыкальный якорь к координате
Музыка = машина времени. Каждый повтор трека = телепорт назад.
"""
coord = self._index_cache.get(coord_id)
if not coord:
return False
coord.music_track = track_name
coord.music_id = track_id
self._save_index()
return True
def get_by_music(self, track_name: str) -> List[Coordinate]:
"""Найти все координаты с определённым треком"""
track_lower = track_name.lower()
return [
c for c in self._index_cache.values()
if c.music_track and track_lower in c.music_track.lower()
]
def get_soundtrack(self, user_id: int) -> List[Dict]:
"""Получить саундтрек пользователя — все треки из памяти"""
coords = [c for c in self._index_cache.values() if c.user_id == user_id and c.music_track]
# Группируем по трекам
tracks = defaultdict(list)
for coord in coords:
tracks[coord.music_track].append(coord.timestamp)
# Сортируем по частоте
result = []
for track, timestamps in sorted(tracks.items(), key=lambda x: -len(x[1])):
result.append({
"track": track,
"count": len(timestamps),
"first": min(timestamps),
"last": max(timestamps)
})
return result
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 2: ГЕОЛОКАЦИЯ
# ═══════════════════════════════════════════════════════════════════════════
def add_location(
self,
coord_id: str,
lat: float,
lon: float,
name: Optional[str] = None
) -> bool:
"""Добавить геолокацию к координате"""
coord = self._index_cache.get(coord_id)
if not coord:
return False
coord.location = f"{lat},{lon}"
coord.location_name = name
self._save_index()
return True
def get_by_location(
self,
lat: float,
lon: float,
radius_km: float = 1.0
) -> List[Coordinate]:
"""Найти координаты рядом с местом"""
results = []
for coord in self._index_cache.values():
if not coord.location:
continue
try:
c_lat, c_lon = map(float, coord.location.split(','))
dist = self._haversine(lat, lon, c_lat, c_lon)
if dist <= radius_km:
results.append(coord)
except:
continue
return results
def get_places(self, user_id: int) -> List[Dict]:
"""Получить все места пользователя"""
coords = [c for c in self._index_cache.values() if c.user_id == user_id and c.location_name]
places = defaultdict(list)
for coord in coords:
places[coord.location_name].append(coord.timestamp)
result = []
for place, timestamps in sorted(places.items(), key=lambda x: -len(x[1])):
result.append({
"place": place,
"count": len(timestamps),
"first": min(timestamps),
"last": max(timestamps)
})
return result
@staticmethod
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Расстояние между точками в км"""
from math import radians, cos, sin, asin, sqrt
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
c = 2 * asin(sqrt(a))
r = 6371 # Радиус Земли в км
return c * r
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 3: MULTI-USER
# ═══════════════════════════════════════════════════════════════════════════
def get_all_users(self) -> List[UserMemory]:
"""Получить всех пользователей с их статистикой"""
user_coords = defaultdict(list)
for coord in self._index_cache.values():
user_coords[coord.user_id].append(coord)
users = []
for user_id, coords in user_coords.items():
coords.sort(key=lambda x: x.timestamp)
# Собираем теги
all_tags = []
for c in coords:
all_tags.extend(c.tags)
tag_counts = defaultdict(int)
for tag in all_tags:
tag_counts[tag] += 1
top_tags = sorted(tag_counts.keys(), key=lambda x: -tag_counts[x])[:5]
# Плотность
if len(coords) >= 2:
first = datetime.fromisoformat(coords[0].timestamp.replace("Z", ""))
last = datetime.fromisoformat(coords[-1].timestamp.replace("Z", ""))
days = max(1, (last - first).days)
density = len(coords) / days
else:
density = len(coords)
# Связанные пользователи
connected = set()
for c in coords:
connected.update(c.shared_with)
users.append(UserMemory(
user_id=user_id,
username=coords[0].username if coords else "unknown",
total_thoughts=len(coords),
first_thought=coords[0].timestamp if coords else None,
last_thought=coords[-1].timestamp if coords else None,
density_per_day=round(density, 2),
top_tags=top_tags,
connected_users=list(connected)
))
return users
def get_global_stats(self) -> Dict:
"""Глобальная статистика гиппокампа"""
coords = list(self._index_cache.values())
if not coords:
return {"total": 0}
users = set(c.user_id for c in coords)
tags = defaultdict(int)
for c in coords:
for tag in c.tags:
tags[tag] += 1
return {
"total_coordinates": len(coords),
"total_users": len(users),
"total_shared": len([c for c in coords if c.shared_with]),
"total_public": len([c for c in coords if c.is_public]),
"top_tags": sorted(tags.items(), key=lambda x: -x[1])[:10],
"first_memory": min(c.timestamp for c in coords),
"last_memory": max(c.timestamp for c in coords)
}
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 3: SHARED MEMORIES
# ═══════════════════════════════════════════════════════════════════════════
def share(self, coord_id: str, with_user_id: int) -> bool:
"""Поделиться координатой с другим пользователем"""
coord = self._index_cache.get(coord_id)
if not coord:
return False
if with_user_id not in coord.shared_with:
coord.shared_with.append(with_user_id)
self._save_index()
# Записываем в shared log
share_entry = {
"coord_id": coord_id,
"from_user": coord.user_id,
"to_user": with_user_id,
"timestamp": datetime.utcnow().isoformat() + "Z"
}
with open(self.shared_file, "a", encoding="utf-8") as f:
f.write(json.dumps(share_entry, ensure_ascii=False) + "\n")
return True
def make_public(self, coord_id: str) -> bool:
"""Сделать координату публичной"""
coord = self._index_cache.get(coord_id)
if not coord:
return False
coord.is_public = True
self._save_index()
return True
def get_shared_with_me(self, user_id: int) -> List[Coordinate]:
"""Получить координаты, которыми поделились со мной"""
return [
c for c in self._index_cache.values()
if user_id in c.shared_with
]
def get_public_stream(self, limit: int = 100) -> List[Coordinate]:
"""Получить публичный поток"""
public = [c for c in self._index_cache.values() if c.is_public]
public.sort(key=lambda x: x.timestamp, reverse=True)
return public[:limit]
# ═══════════════════════════════════════════════════════════════════════════
# ФАЗА 3: CROSS-REFERENCE
# ═══════════════════════════════════════════════════════════════════════════
def _extract_tags(self, text: str) -> List[str]:
"""Извлечь теги из текста"""
# #теги
hashtags = re.findall(r'#(\w+)', text)
# Ключевые слова Montana
keywords = ['время', 'память', 'монтана', 'юнона', 'гиппокамп', 'координата',
'time', 'memory', 'montana', 'juno', 'hippocampus', 'coordinate']
text_lower = text.lower()
found_keywords = [kw for kw in keywords if kw in text_lower]
return list(set(hashtags + found_keywords))
def _find_references(self, coord: Coordinate) -> List[str]:
"""Найти связанные координаты"""
references = []
# По тегам
if coord.tags:
for other in self._index_cache.values():
if other.id == coord.id:
continue
if any(tag in other.tags for tag in coord.tags):
references.append(other.id)
# По похожести текста (простая эвристика)
words = set(coord.thought.lower().split())
if len(words) >= 3:
for other in self._index_cache.values():
if other.id == coord.id:
continue
other_words = set(other.thought.lower().split())
common = words & other_words
if len(common) >= 3:
if other.id not in references:
references.append(other.id)
return references[:10] # Максимум 10 связей
def link(self, coord_id_1: str, coord_id_2: str) -> bool:
"""Создать связь между координатами вручную"""
coord1 = self._index_cache.get(coord_id_1)
coord2 = self._index_cache.get(coord_id_2)
if not coord1 or not coord2:
return False
if coord_id_2 not in coord1.references:
coord1.references.append(coord_id_2)
if coord_id_1 not in coord2.references:
coord2.references.append(coord_id_1)
self._references_cache[coord_id_1].add(coord_id_2)
self._references_cache[coord_id_2].add(coord_id_1)
self._save_index()
self._save_references()
return True
def get_related(self, coord_id: str, depth: int = 1) -> List[Coordinate]:
"""Получить связанные координаты (с глубиной)"""
coord = self._index_cache.get(coord_id)
if not coord:
return []
visited = {coord_id}
current_level = set(coord.references)
all_related = []
for _ in range(depth):
next_level = set()
for ref_id in current_level:
if ref_id in visited:
continue
visited.add(ref_id)
ref_coord = self._index_cache.get(ref_id)
if ref_coord:
all_related.append(ref_coord)
next_level.update(ref_coord.references)
current_level = next_level
return all_related
def get_graph(self, user_id: Optional[int] = None) -> Dict:
"""Получить граф связей для визуализации"""
coords = list(self._index_cache.values())
if user_id:
coords = [c for c in coords if c.user_id == user_id]
nodes = []
edges = []
for coord in coords:
nodes.append({
"id": coord.id,
"label": coord.thought[:30] + "...",
"timestamp": coord.timestamp,
"tags": coord.tags
})
for ref_id in coord.references:
if ref_id in self._index_cache:
edges.append({
"from": coord.id,
"to": ref_id
})
return {"nodes": nodes, "edges": edges}
# ═══════════════════════════════════════════════════════════════════════════
# ВСПОМОГАТЕЛЬНЫЕ
# ═══════════════════════════════════════════════════════════════════════════
def _filter_coords(
self,
user_id: Optional[int] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None
) -> List[Coordinate]:
"""Фильтрация координат"""
coords = list(self._index_cache.values())
if user_id:
coords = [c for c in coords if c.user_id == user_id]
if from_date:
coords = [c for c in coords if c.timestamp[:10] >= from_date]
if to_date:
coords = [c for c in coords if c.timestamp[:10] <= to_date]
coords.sort(key=lambda x: x.timestamp)
return coords
def _load_index(self):
"""Загрузить индекс из файла"""
if self.stream_file.exists():
with open(self.stream_file, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
try:
data = json.loads(line)
coord = Coordinate.from_dict(data)
self._index_cache[coord.id] = coord
except:
continue
def _save_index(self):
"""Сохранить индекс (перезаписать stream)"""
with open(self.stream_file, "w", encoding="utf-8") as f:
for coord in sorted(self._index_cache.values(), key=lambda x: x.timestamp):
f.write(json.dumps(coord.to_dict(), ensure_ascii=False) + "\n")
def _load_references(self):
"""Загрузить граф связей"""
if self.references_file.exists():
try:
with open(self.references_file, "r", encoding="utf-8") as f:
data = json.load(f)
for k, v in data.items():
self._references_cache[k] = set(v)
except:
pass
def _save_references(self):
"""Сохранить граф связей"""
data = {k: list(v) for k, v in self._references_cache.items()}
with open(self.references_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
# ═══════════════════════════════════════════════════════════════════════════════
# CLI
# ═══════════════════════════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser(
description="Внешний Гиппокамп Montana — Полная реализация",
formatter_class=argparse.RawDescriptionHelpFormatter
)
subparsers = parser.add_subparsers(dest='command', help='Команды')
# search
search_parser = subparsers.add_parser('search', help='Поиск по памяти')
search_parser.add_argument('query', help='Поисковый запрос')
search_parser.add_argument('--user', type=int, help='Фильтр по user_id')
search_parser.add_argument('--limit', type=int, default=10)
# export
export_parser = subparsers.add_parser('export', help='Экспорт в Markdown')
export_parser.add_argument('--user', type=int, help='Фильтр по user_id')
export_parser.add_argument('--from', dest='from_date', help='С даты')
export_parser.add_argument('--to', dest='to_date', help='По дату')
export_parser.add_argument('--output', '-o', help='Файл для сохранения')
# plot
plot_parser = subparsers.add_parser('plot', help='Визуализация плотности')
plot_parser.add_argument('--user', type=int, help='Фильтр по user_id')
plot_parser.add_argument('--period', default='month', choices=['day', 'week', 'month', 'year', 'all'])
plot_parser.add_argument('--output', '-o', help='Файл PNG')
# stats
subparsers.add_parser('stats', help='Глобальная статистика')
# users
subparsers.add_parser('users', help='Список пользователей')
# graph
graph_parser = subparsers.add_parser('graph', help='Граф связей (JSON)')
graph_parser.add_argument('--user', type=int)
args = parser.parse_args()
hip = ExternalHippocampus()
if args.command == 'search':
results = hip.search(args.query, user_id=args.user, limit=args.limit)
print(f"Ɉ Найдено: {len(results)} координат\n")
for coord in results:
print(f"[{coord.timestamp[:16]}] {coord.thought[:60]}...")
if coord.tags:
print(f" Теги: {', '.join(coord.tags)}")
print()
elif args.command == 'export':
md = hip.export_markdown(
user_id=args.user,
from_date=args.from_date,
to_date=args.to_date
)
if args.output:
Path(args.output).write_text(md, encoding='utf-8')
print(f"✓ Сохранено в {args.output}")
else:
print(md)
elif args.command == 'plot':
result = hip.plot_density(
user_id=args.user,
period=args.period,
output_path=args.output
)
print(f"✓ График: {result}")
elif args.command == 'stats':
stats = hip.get_global_stats()
print("Ɉ Статистика Внешнего Гиппокампа")
print()
print(f" Всего координат: {stats.get('total_coordinates', 0)}")
print(f" Пользователей: {stats.get('total_users', 0)}")
print(f" Shared: {stats.get('total_shared', 0)}")
print(f" Public: {stats.get('total_public', 0)}")
print()
if stats.get('top_tags'):
print(" Топ теги:")
for tag, count in stats['top_tags'][:5]:
print(f" #{tag}: {count}")
elif args.command == 'users':
users = hip.get_all_users()
print(f"Ɉ Пользователи ({len(users)})\n")
for user in users:
print(f" @{user.username} (ID: {user.user_id})")
print(f" Мыслей: {user.total_thoughts}")
print(f" Плотность: {user.density_per_day}/день")
if user.top_tags:
print(f" Теги: {', '.join(user.top_tags)}")
print()
elif args.command == 'graph':
graph = hip.get_graph(user_id=args.user)
print(json.dumps(graph, ensure_ascii=False, indent=2))
else:
parser.print_help()
if __name__ == "__main__":
main()