File size: 14,744 Bytes
cd7e7a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | # src/chat_processor.py
import logging
import math
import re
import time
from collections import Counter
from typing import List, Dict, Any, Optional, Tuple
from src.chat_helpers import extract_urls
from src.youtube_handler import is_youtube_url
from src.search import comprehensive_web_search, fetch_webpage_content
from src.prompt_security import UNTRUSTED_CONTEXT_POLICY, untrusted_context_message
logger = logging.getLogger(__name__)
# ── Stopwords & tokenizer ──
_STOPWORDS = frozenset(
"a an the is am are was were be been being have has had do does did "
"will would shall should can could may might must need ought dare "
"i me my mine we us our ours you your yours he him his she her hers "
"it its they them their theirs this that these those "
"and but or nor not no so if then else than too also very "
"in on at to for of by with from up out about into over after "
"what when where which who whom how why all each every some any "
"just very really actually like well also still already even "
"oh ok okay yes yeah hey hi hello thanks thank please sorry "
"much more most own other another such only same here there "
"because while during before until since through between both "
"few many several some none nothing something anything everything "
"get got make made go going went been come came take took "
"know think want let say tell give see look find way thing "
"don doesn didn won wouldn couldn shouldn wasn weren isn aren haven hasn "
"don't doesn't didn't won't wouldn't couldn't shouldn't "
"it's i'm i've i'll i'd you're you've you'll he's she's we're we've they're they've "
"that's there's here's what's who's how's let's can't".split()
)
def _content_tokens(text: str) -> list:
"""Extract meaningful content words: no stopwords, min 3 chars, lowercase."""
words = re.findall(r'[a-z0-9]+(?:[-_][a-z0-9]+)*', text.lower())
return [w for w in words if len(w) >= 3 and w not in _STOPWORDS]
class ChatProcessor:
def __init__(self, memory_manager, personal_docs_manager, memory_vector=None, skills_manager=None):
self.memory_manager = memory_manager
self.personal_docs_manager = personal_docs_manager
self.memory_vector = memory_vector
self.skills_manager = skills_manager
# Minimum similarity score for RAG results to be injected
RAG_SIMILARITY_THRESHOLD = 0.35
def _hybrid_retrieve(self, message: str, mem_entries: list, k: int = 5) -> list:
"""Retrieve memories relevant to the message.
Uses BM25-style keyword scoring + optional vector similarity.
Recency is a tiebreaker only, never the primary signal.
"""
if not mem_entries or not message.strip():
return []
now = time.time()
query_tokens = _content_tokens(message)
# If the query has no meaningful tokens, skip keyword retrieval entirely
if not query_tokens:
# Fall back to vector-only if available
if not (self.memory_vector and self.memory_vector.healthy):
return []
# ── Build IDF from the memory corpus ──
N = len(mem_entries)
doc_freq = Counter() # token -> how many memories contain it
mem_token_cache = {} # mem_id -> set of content tokens
for mem in mem_entries:
toks = set(_content_tokens(mem["text"]))
mem_token_cache[mem["id"]] = toks
for t in toks:
doc_freq[t] += 1
def _bm25_score(query_toks, mem_id):
"""BM25-inspired score between query and a memory."""
mem_toks = mem_token_cache.get(mem_id, set())
if not mem_toks or not query_toks:
return 0.0
score = 0.0
mem_len = len(mem_toks)
avg_len = max(sum(len(v) for v in mem_token_cache.values()) / N, 1)
k1, b = 1.5, 0.75
for qt in query_toks:
if qt not in mem_toks:
continue
df = doc_freq.get(qt, 0)
idf = math.log((N - df + 0.5) / (df + 0.5) + 1)
tf = 1 # binary presence (memory entries are short)
tf_norm = (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * mem_len / avg_len))
score += idf * tf_norm
return score
# ── Score all candidates ──
has_vector = self.memory_vector and self.memory_vector.healthy
vector_scores = {}
if has_vector:
results = self.memory_vector.search(message, k=min(k * 3, 20))
mem_by_id = {m["id"]: m for m in mem_entries}
for r in results:
if r["memory_id"] in mem_by_id:
vector_scores[r["memory_id"]] = max(r["score"], 0.0)
scored = []
for mem in mem_entries:
mid = mem["id"]
vs = vector_scores.get(mid, 0.0)
kw = _bm25_score(query_tokens, mid)
# Normalize BM25 to roughly 0-1 range (cap at a reasonable max)
kw_norm = min(kw / 6.0, 1.0) if kw > 0 else 0.0
# Category-aware boost for identity/contact queries
category = mem.get("category", "fact")
msg_lower = message.lower()
mem_lower = mem["text"].lower()
cat_boost = 1.0
if any(w in msg_lower for w in ["name", "who am i", "my name"]):
if category == "identity" or any(w in mem_lower for w in ["name is", "i am", "called"]):
cat_boost = 1.4
elif any(w in msg_lower for w in ["phone", "email", "address", "contact"]):
if category == "contact" or "@" in mem_lower:
cat_boost = 1.3
elif any(w in msg_lower for w in ["like", "prefer", "favorite"]):
if category == "preference":
cat_boost = 1.2
kw_norm = min(kw_norm * cat_boost, 1.0)
# Recency — tiebreaker only (max 5% contribution)
ts = mem.get("timestamp", 0)
days_old = max((now - ts) / 86400, 0)
recency = 1.0 / (1.0 + days_old * 0.05)
# Gate: need real relevance, not just recency
if has_vector:
if vs < 0.20 and kw_norm < 0.08:
continue
final = (0.55 * vs) + (0.40 * kw_norm) + (0.05 * recency)
else:
if kw_norm < 0.08:
continue
final = (0.95 * kw_norm) + (0.05 * recency)
if final > 0.12:
scored.append((final, mem))
scored.sort(key=lambda x: x[0], reverse=True)
return [mem for _, mem in scored[:k]]
def build_context_preface(
self,
message: str,
session: Any,
use_web: bool = False,
use_rag: bool = True,
use_memory: bool = True,
time_filter: Optional[str] = None,
preset_system_prompt: Optional[str] = None,
owner: Optional[str] = None,
character_name: Optional[str] = None,
agent_mode: bool = False,
incognito: bool = False,
use_skills: bool = True,
) -> Tuple[List[Dict[str, str]], List[Dict[str, Any]], List[Dict[str, str]]]:
"""Build the context preface for LLM calls.
Returns:
Tuple of (preface messages, rag_sources list)
"""
preface = []
rag_sources = []
# Add preset system prompt if specified
if preset_system_prompt:
preface.append({
"role": "system",
"content": preset_system_prompt
})
preface.append({
"role": "system",
"content": UNTRUSTED_CONTEXT_POLICY,
})
# Memory: pinned (always included) + extended (RAG-retrieved when relevant)
self._last_used_memories = [] # track what was injected
if use_memory:
mem_entries = self.memory_manager.load(owner=owner)
pinned = [m for m in mem_entries if m.get("pinned")]
extended = [m for m in mem_entries if not m.get("pinned")]
_used_ids: list = []
if pinned:
pinned_text = "\n- ".join([m["text"] for m in pinned])
preface.append(untrusted_context_message(
"saved memory: pinned user facts",
f"Core facts about the user:\n- {pinned_text}",
))
for m in pinned:
self._last_used_memories.append({"text": m["text"], "category": m.get("category", "fact"), "type": "pinned"})
if m.get("id"):
_used_ids.append(m["id"])
if extended:
relevant = self._hybrid_retrieve(message, extended, k=3)
if relevant:
ext_text = "\n".join([f"- {m['text']}" for m in relevant])
preface.append(untrusted_context_message(
"saved memory: retrieved context",
(
"Memory context. Do not reference unless the user asks "
f"about these topics.\n{ext_text}"
),
))
for m in relevant:
self._last_used_memories.append({"text": m["text"], "category": m.get("category", "fact"), "type": "recalled"})
if m.get("id"):
_used_ids.append(m["id"])
# Bump usage counters for the memories that were actually injected.
if _used_ids and hasattr(self.memory_manager, "increment_uses"):
try:
self.memory_manager.increment_uses(_used_ids)
except Exception as _e:
logger.warning("Failed to increment memory uses: %s", _e)
# (skills index injection moved out — see below; only fires in
# agent mode so chat mode and incognito stay clean.)
# RAG: search if enabled and rag_manager available, inject only above threshold
if use_rag:
try:
rag_manager = getattr(self.personal_docs_manager, 'rag_manager', None)
if rag_manager:
results = rag_manager.search(message, k=5, owner=owner)
# Filter by similarity threshold
relevant = [r for r in results if r.get("similarity", 0) >= self.RAG_SIMILARITY_THRESHOLD]
if relevant:
logger.info(f"RAG: {len(relevant)}/{len(results)} results above threshold {self.RAG_SIMILARITY_THRESHOLD}")
rag_sources = [
{
"filename": r["metadata"].get("filename", r["metadata"].get("source", "unknown")),
"snippet": r["document"][:200],
"similarity": round(r.get("similarity", 0), 3)
}
for r in relevant
]
rag_content = "Relevant documents:\n\n" + "\n\n---\n\n".join(
f"[{s['filename']}]\n{r['document']}" for s, r in zip(rag_sources, relevant)
)
if len(rag_content) > 10000:
rag_content = rag_content[:10000] + "\n[Truncated]"
preface.append(untrusted_context_message("retrieved documents", rag_content))
except Exception as e:
logger.warning(f"RAG retrieval failed: {e}")
# Add web search if enabled
web_sources = []
if use_web:
try:
web_context, web_sources = comprehensive_web_search(
message, time_filter=time_filter, return_sources=True
)
preface.append(untrusted_context_message("web search results", web_context))
except Exception as e:
logger.error(f"Web search failed: {e}")
preface.append({"role": "system", "content": "Web search encountered an error and could not retrieve results."})
# Process non-YouTube URLs in message (YouTube handled by preprocess_message)
# Skip auto-fetch for long pastes (the user already pasted the content —
# fetching every embedded link buries the actual question under
# hundreds of KB of duplicate page HTML and confuses the model) or for
# link-heavy pastes (>3 URLs typically means it's a boilerplate-laden
# blog post, not a "summarize this URL" request).
urls = extract_urls(message)
non_yt_urls = [u for u in urls if not is_youtube_url(u)]
skip_url_fetch = len(message) > 2000 or len(non_yt_urls) > 3
if not skip_url_fetch:
for url in non_yt_urls:
result = fetch_webpage_content(url)
if result.get('success'):
content = result.get('content', '')[:10000]
preface.append(untrusted_context_message(
f"web page: {url}",
f"Content from {url}:\n\n{content}",
))
# Skills index — progressive disclosure. Only injected when the
# model has the `manage_skills` tool available (agent_mode), and
# never in incognito mode (the user has explicitly opted out of
# context retention this turn). In plain chat mode the model can't
# call the tool anyway, so the index would be noise.
if agent_mode and not incognito and use_skills and self.skills_manager:
try:
idx = self.skills_manager.index_for(owner=owner)
except Exception as e:
logger.debug(f"Skills index unavailable: {e}")
idx = []
if idx:
by_cat: Dict[str, list] = {}
for s in idx:
by_cat.setdefault(s.get("category") or "general", []).append(s)
lines = ["[Available skills — call manage_skills(action='view', name='...') to load one when relevant]"]
for cat in sorted(by_cat):
lines.append(f" {cat}:")
for s in sorted(by_cat[cat], key=lambda x: x["name"]):
desc = s.get("description") or ""
lines.append(f" - {s['name']}: {desc}" if desc else f" - {s['name']}")
preface.append(untrusted_context_message("available skills index", "\n".join(lines)))
return preface, rag_sources, web_sources
|