hermescures1's picture
Upload folder using huggingface_hub
32112fa verified
Raw
History Blame Contribute Delete
10.1 kB
"""Recursive Link Memory — knowledge graph for conversation contexts.
Every conversation turn is stored as a linked context.
Related contexts are auto-linked (keyword overlap, semantic similarity).
During inference: inject linked context as compressed prefix.
Links decay over time, reinforced by co-access.
Persistent storage in SQLite.
"""
from __future__ import annotations
import hashlib
import logging
import os
import sqlite3
import time
from collections import defaultdict, deque
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class Context:
"""A conversation context stored in the recursive link graph."""
id: str
user_message: str
assistant_response: str
session_id: str = ""
channel: str = "cli"
timestamp: float = field(default_factory=time.time)
access_count: int = 0
keywords: list[str] = field(default_factory=list)
@dataclass
class Link:
"""A link between two contexts in the recursive link graph."""
source_id: str
target_id: str
strength: float = 1.0
created_at: float = field(default_factory=time.time)
last_accessed: float = field(default_factory=time.time)
access_count: int = 0
class RecursiveLinkGraph:
"""Recursive link graph for conversation memory.
Features:
- Context storage (every conversation turn)
- Auto-linking based on keyword overlap
- Link traversal (find related contexts)
- Link decay (stale links fade over time)
- Link reinforcement (co-access strengthens links)
- Persistent storage in SQLite
"""
DECAY_RATE = 0.001 # per second
MIN_STRENGTH = 0.01
MAX_LINKS_PER_CONTEXT = 50
def __init__(self, db_path: str | None = None) -> None:
self.db_path = db_path
self._contexts: dict[str, Context] = {}
self._links: dict[str, list[Link]] = defaultdict(list)
self._keyword_index: dict[str, set[str]] = defaultdict(set)
self._stats = {
"contexts_stored": 0,
"links_created": 0,
"links_traversed": 0,
"contexts_injected": 0,
}
if db_path:
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
self._init_db()
self._load_from_db()
def _init_db(self) -> None:
"""Initialize SQLite database."""
with sqlite3.connect(self.db_path) as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS contexts (
id TEXT PRIMARY KEY,
user_message TEXT,
assistant_response TEXT,
session_id TEXT,
channel TEXT,
timestamp REAL,
access_count INTEGER,
keywords TEXT
);
CREATE TABLE IF NOT EXISTS links (
source_id TEXT,
target_id TEXT,
strength REAL,
created_at REAL,
last_accessed REAL,
access_count INTEGER,
PRIMARY KEY (source_id, target_id)
);
CREATE INDEX IF NOT EXISTS idx_keywords ON contexts(keywords);
""")
def _load_from_db(self) -> None:
"""Load contexts and links from SQLite."""
with sqlite3.connect(self.db_path) as conn:
for row in conn.execute("SELECT * FROM contexts"):
ctx = Context(
id=row[0], user_message=row[1], assistant_response=row[2],
session_id=row[3], channel=row[4], timestamp=row[5],
access_count=row[6],
keywords=row[7].split(",") if row[7] else [],
)
self._contexts[ctx.id] = ctx
for kw in ctx.keywords:
self._keyword_index[kw].add(ctx.id)
for row in conn.execute("SELECT * FROM links"):
link = Link(
source_id=row[0], target_id=row[1], strength=row[2],
created_at=row[3], last_accessed=row[4], access_count=row[5],
)
self._links[link.source_id].append(link)
logger.info("Loaded %d contexts, %d links from DB", len(self._contexts), sum(len(v) for v in self._links.values()))
def _save_context(self, ctx: Context) -> None:
if not self.db_path:
return
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"INSERT OR REPLACE INTO contexts VALUES (?,?,?,?,?,?,?,?)",
(ctx.id, ctx.user_message, ctx.assistant_response, ctx.session_id,
ctx.channel, ctx.timestamp, ctx.access_count, ",".join(ctx.keywords))
)
def _save_link(self, link: Link) -> None:
if not self.db_path:
return
with sqlite3.connect(self.db_path) as conn:
conn.execute(
"INSERT OR REPLACE INTO links VALUES (?,?,?,?,?,?)",
(link.source_id, link.target_id, link.strength,
link.created_at, link.last_accessed, link.access_count)
)
def _extract_keywords(self, text: str) -> list[str]:
"""Extract keywords from text (simple word frequency)."""
words = text.lower().split()
# Remove very common words
stop = {"the", "a", "an", "is", "are", "was", "were", "be", "been",
"have", "has", "had", "do", "does", "did", "will", "would",
"could", "should", "may", "might", "can", "to", "of", "in",
"on", "at", "by", "for", "with", "about", "as", "into", "like",
"through", "after", "over", "between", "out", "against",
"during", "without", "before", "under", "around", "among"}
keywords = [w for w in words if len(w) > 2 and w not in stop]
return list(set(keywords))[:20]
def add_context(self, user_message: str, assistant_response: str,
session_id: str = "", channel: str = "cli") -> str:
"""Add a conversation context to the graph and auto-link it."""
ctx_id = hashlib.sha256(
f"{user_message}:{assistant_response}:{time.time()}".encode()
).hexdigest()[:16]
keywords = self._extract_keywords(user_message + " " + assistant_response)
ctx = Context(
id=ctx_id, user_message=user_message, assistant_response=assistant_response,
session_id=session_id, channel=channel, keywords=keywords,
)
self._contexts[ctx_id] = ctx
self._stats["contexts_stored"] += 1
# Index keywords
for kw in keywords:
self._keyword_index[kw].add(ctx_id)
# Auto-link to related contexts
self._auto_link(ctx_id, keywords)
# Save to DB
self._save_context(ctx)
return ctx_id
def _auto_link(self, ctx_id: str, keywords: list[str]) -> None:
"""Automatically create links to contexts with overlapping keywords."""
related: dict[str, int] = defaultdict(int)
for kw in keywords:
for other_id in self._keyword_index.get(kw, set()):
if other_id != ctx_id:
related[other_id] += 1
# Sort by overlap count and create links
for other_id, overlap in sorted(related.items(), key=lambda x: -x[1])[:self.MAX_LINKS_PER_CONTEXT]:
strength = min(1.0, overlap / max(len(keywords), 1))
link = Link(source_id=ctx_id, target_id=other_id, strength=strength)
self._links[ctx_id].append(link)
self._stats["links_created"] += 1
self._save_link(link)
# Bidirectional link
rev_link = Link(source_id=other_id, target_id=ctx_id, strength=strength)
self._links[other_id].append(rev_link)
self._save_link(rev_link)
def find_related(self, message: str, max_results: int = 3) -> list[Context]:
"""Find contexts related to the given message."""
keywords = self._extract_keywords(message)
related: dict[str, float] = defaultdict(float)
for kw in keywords:
for ctx_id in self._keyword_index.get(kw, set()):
if ctx_id in self._contexts:
# Apply link decay
ctx = self._contexts[ctx_id]
age = time.time() - ctx.timestamp
decayed_strength = max(self.MIN_STRENGTH, 1.0 - age * self.DECAY_RATE)
related[ctx_id] += decayed_strength
# Sort by relevance
sorted_ids = sorted(related.items(), key=lambda x: -x[1])[:max_results]
results = []
for ctx_id, score in sorted_ids:
ctx = self._contexts[ctx_id]
ctx.access_count += 1
self._stats["links_traversed"] += 1
results.append(ctx)
return results
def get_injection_context(self, message: str) -> str:
"""Get related context text to inject as a prefix for inference."""
related = self.find_related(message, max_results=3)
if not related:
return ""
self._stats["contexts_injected"] += 1
parts = []
for ctx in related:
parts.append(f"Previous: Q: {ctx.user_message[:100]} A: {ctx.assistant_response[:100]}")
return " | ".join(parts)
def decay_links(self) -> None:
"""Apply time-based decay to all links. Call periodically."""
now = time.time()
for ctx_id, links in self._links.items():
for link in links:
age = now - link.last_accessed
link.strength = max(self.MIN_STRENGTH, link.strength - age * self.DECAY_RATE)
def get_stats(self) -> dict[str, Any]:
return {
**self._stats,
"total_contexts": len(self._contexts),
"total_links": sum(len(v) for v in self._links.values()),
"keyword_index_size": len(self._keyword_index),
}