Spaces:
Sleeping
Sleeping
Claude Opus 4.8 (1M context)
fix: centralize Supabase client on HTTP/1.1 to stop RemoteProtocolError 500s
6d936a1 Download db_5_process_session.py from alesamodio/Socrates_docker: direct link, hf CLI and curl.
- Browser
- Download file 15.9 kB
-
https://huggingface.co/spaces/alesamodio/Socrates_docker/resolve/main/db_5_process_session.py
- Command line
-
hf download hf://spaces/alesamodio/Socrates_docker/db_5_process_session.py
-
curl -L -o db_5_process_session.py https://huggingface.co/spaces/alesamodio/Socrates_docker/resolve/main/db_5_process_session.py
15.9 kB
| """ | |
| db_5_process_session.py | |
| Short-term chat history management + long-term FAISS archiving (db5). | |
| Short-term window (chat_history_short): | |
| - Keep: last 10 days AND at least 40 messages | |
| - Trim when: oldest session > 21 days OR total messages > 200 | |
| - Trimmed closed sessions are chunked and archived to FAISS db5 | |
| FAISS db5 (long-term): | |
| - Embedding model: intfloat/e5-large-v2 (1024 dims, same as all other DBs) | |
| - Two documents per chunk: raw turns text + LLM summary (multi-vector) | |
| - Rich metadata: chunk_id, session_id, timestamps, character, turn_range | |
| - Long-term history recalled only on explicit memory-reference queries | |
| (see classify_memory_reference.py) | |
| """ | |
| import os | |
| from pathlib import Path | |
| from datetime import datetime, timedelta, timezone | |
| from tempfile import TemporaryDirectory | |
| from typing import Optional | |
| from langchain_huggingface import HuggingFaceEmbeddings | |
| from langchain_community.vectorstores import FAISS | |
| from langchain_core.documents import Document as LangchainDocument | |
| from supabase import create_client | |
| from config import SUPABASE_URL, SUPABASE_SERVICE_KEY, HF_EMBEDDING_MODEL | |
| from db_user import _load_history, _save_history | |
| from db_client import supabase | |
| # ββ Short-term window ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _MIN_DAYS = 10 | |
| _MAX_DAYS = 21 | |
| _MIN_MESSAGES = 40 | |
| _MAX_MESSAGES = 200 | |
| _SESSION_IDLE_MINUTES = 360 # 6 hours | |
| # ββ Chunking βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _CHUNK_WINDOW = 8 # messages per chunk | |
| _CHUNK_OVERLAP = 2 # messages shared between adjacent chunks | |
| _CHUNK_STEP = _CHUNK_WINDOW - _CHUNK_OVERLAP # = 6 | |
| _MID_SESSION_SNAPSHOT_EVERY_N = 30 | |
| # ββ Embedding model (lazy singleton) ββββββββββββββββββββββββββββββββββββββββββ | |
| _embedding_model: Optional[HuggingFaceEmbeddings] = None | |
| def _get_embeddings() -> HuggingFaceEmbeddings: | |
| global _embedding_model | |
| if _embedding_model is None: | |
| _embedding_model = HuggingFaceEmbeddings( | |
| model_name=HF_EMBEDDING_MODEL, | |
| encode_kwargs={"normalize_embeddings": True}, | |
| ) | |
| return _embedding_model | |
| # ββ Time helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _now_utc() -> datetime: | |
| return datetime.now(timezone.utc).replace(tzinfo=None) | |
| def _parse_ts(ts_str: Optional[str]) -> Optional[datetime]: | |
| if not ts_str: | |
| return None | |
| try: | |
| return datetime.fromisoformat(ts_str.replace("Z", "")) | |
| except Exception: | |
| return None | |
| def _age_days(dt: Optional[datetime]) -> float: | |
| if dt is None: | |
| return 0.0 | |
| return (_now_utc() - dt).total_seconds() / 86400.0 | |
| # ββ Session helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _new_session(session_id: int, now: str) -> dict: | |
| return { | |
| "session_id": session_id, | |
| "status": "open", | |
| "date_opened": now, | |
| "date_closed": None, | |
| "messages": [], | |
| } | |
| def _total_messages(sessions: list) -> int: | |
| return sum(len(s.get("messages", [])) for s in sessions) | |
| def close_current_session_if_expired( | |
| max_idle_minutes: int = _SESSION_IDLE_MINUTES, user_id: str = "None" | |
| ): | |
| """Close the current session if idle too long.""" | |
| short = _load_history("chat_history_short", user_id) | |
| if not short["sessions"]: | |
| return short | |
| current = short["sessions"][-1] | |
| msgs = current.get("messages", []) | |
| if not msgs: | |
| return short | |
| last_ts = msgs[-1].get("time") or msgs[-1].get("timestamp") | |
| last_dt = _parse_ts(last_ts) | |
| if last_dt and _now_utc() - last_dt > timedelta(minutes=max_idle_minutes): | |
| current["status"] = "closed" | |
| current["date_closed"] = _now_utc().isoformat() | |
| _save_history("chat_history_short", short, user_id) | |
| return short | |
| # ββ Save message βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_chat_message( | |
| message_en: dict, message_user_language: dict, user_id: str = "None" | |
| ): | |
| """Append a message to both short-term and total history in Supabase.""" | |
| now = _now_utc().isoformat() | |
| if isinstance(message_user_language, str): | |
| message_user_language = {"lang": message_user_language} | |
| for msg in (message_en, message_user_language): | |
| if "time" not in msg: | |
| msg["time"] = now | |
| # total history (user language, for UI) | |
| total = _load_history("chat_history_total", user_id) | |
| if not total["sessions"]: | |
| total["sessions"].append(_new_session(1, now)) | |
| total["sessions"][-1]["messages"].append(message_user_language) | |
| _save_history("chat_history_total", total, user_id) | |
| # short history (English, for reasoning) | |
| short = _load_history("chat_history_short", user_id) | |
| if not short["sessions"]: | |
| short["sessions"].append(_new_session(1, now)) | |
| short["sessions"][-1]["messages"].append(message_en) | |
| _save_history("chat_history_short", short, user_id) | |
| # ββ Short-term trimming ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _trim_short_history_if_needed(user_id: str, username: str) -> None: | |
| """ | |
| Remove oldest closed sessions that exceed the time/count window and | |
| archive them to FAISS db5. Always keeps at least _MIN_DAYS of history | |
| and _MIN_MESSAGES messages. | |
| """ | |
| short = _load_history("chat_history_short", user_id) | |
| sessions = short["sessions"] | |
| changed = False | |
| while len(sessions) > 1: | |
| oldest = sessions[0] | |
| # only remove closed sessions | |
| if oldest.get("status") != "closed": | |
| break | |
| age = _age_days(_parse_ts(oldest.get("date_opened"))) | |
| total = _total_messages(sessions) | |
| remaining_after = total - len(oldest.get("messages", [])) | |
| must_trim = (age > _MAX_DAYS) or (total > _MAX_MESSAGES) | |
| # allow trim only if enough messages remain, or session is very old | |
| safe_to_trim = (remaining_after >= _MIN_MESSAGES) or (age > _MAX_DAYS * 2) | |
| if must_trim and safe_to_trim: | |
| _archive_sessions_to_faiss([oldest], username=username) | |
| sessions.pop(0) | |
| changed = True | |
| else: | |
| break | |
| if changed: | |
| short["sessions"] = sessions | |
| _save_history("chat_history_short", short, user_id) | |
| # ββ Mid-session snapshot βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _mid_session_faiss_snapshot_if_needed(user_id: str, username: str) -> None: | |
| """Snapshot the open session to FAISS every N messages so db5 stays fresh.""" | |
| try: | |
| short = _load_history("chat_history_short", user_id) | |
| if not short["sessions"]: | |
| return | |
| current = short["sessions"][-1] | |
| msg_count = len(current.get("messages", [])) | |
| if msg_count > 0 and msg_count % _MID_SESSION_SNAPSHOT_EVERY_N == 0: | |
| chunks = _make_chunks(current["messages"], session_id=current["session_id"]) | |
| if chunks: | |
| _add_chunks_to_faiss(chunks, username=username) | |
| print(f"[db5] Mid-session snapshot at {msg_count} messages for {user_id}") | |
| except Exception as e: | |
| print(f"[db5] Mid-session snapshot error: {e}") | |
| # ββ Main tick ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def on_pre_message_tick(user_id: str, username: str): | |
| """Called before each new user message: expire, trim, snapshot.""" | |
| close_current_session_if_expired(user_id=user_id) | |
| _trim_short_history_if_needed(user_id=user_id, username=username) | |
| _mid_session_faiss_snapshot_if_needed(user_id=user_id, username=username) | |
| # ββ Context retrieval (used by app_nn.py) βββββββββββββββββββββββββββββββββββββ | |
| def _prepare_chat_history_for_retrieval(user_id: str = "None", max_turns: int = 20) -> list: | |
| """Return recent messages from the current session for prompt context.""" | |
| short = _load_history("chat_history_short", user_id) | |
| if not short["sessions"]: | |
| return [] | |
| return short["sessions"][-1]["messages"][-max_turns:] | |
| # ββ Sliding-window chunker βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _format_turn(msg: dict) -> str: | |
| role = msg.get("role", "?") | |
| if role == "assistant": | |
| speaker = (msg.get("character_id") or "assistant").upper() | |
| else: | |
| speaker = "USER" | |
| content = msg.get("content", "").strip() | |
| return f"{speaker}: {content}" | |
| def _make_chunks(messages: list, session_id) -> list[dict]: | |
| """ | |
| Slice a message list into overlapping windows. | |
| Returns list of chunk dicts with text, metadata fields, and turn positions. | |
| """ | |
| if not messages: | |
| return [] | |
| n = len(messages) | |
| starts = list(range(0, max(1, n - _CHUNK_WINDOW + 1), _CHUNK_STEP)) | |
| if not starts: | |
| starts = [0] | |
| chunks = [] | |
| for pos in starts: | |
| window = messages[pos: pos + _CHUNK_WINDOW] | |
| text = "\n".join(_format_turn(m) for m in window) | |
| ts_start = window[0].get("time") or window[0].get("timestamp") or "" | |
| ts_end = window[-1].get("time") or window[-1].get("timestamp") or "" | |
| character = next( | |
| (m.get("character") for m in window if m.get("character")), "" | |
| ) or "" | |
| chunks.append({ | |
| "chunk_id": f"{session_id}_{pos}", | |
| "session_id": str(session_id), | |
| "turn_start": pos, | |
| "turn_end": pos + len(window) - 1, | |
| "timestamp_start": ts_start, | |
| "timestamp_end": ts_end, | |
| "character": character, | |
| "text": text, | |
| }) | |
| return chunks | |
| # ββ Per-chunk LLM summary (Tier C / nano) βββββββββββββββββββββββββββββββββββββ | |
| _SUMMARY_PROMPT = ( | |
| "Summarise this chat exchange in 2-3 sentences. " | |
| "Capture: main topic(s), any opinion or position the user expressed, " | |
| "and any tension or contradiction that emerged. Be concise.\n\n" | |
| ) | |
| def _summarise_chunk(chunk_text: str) -> str: | |
| try: | |
| from llm_client import client, get_model | |
| resp = client.chat.completions.create( | |
| model=get_model("C"), | |
| messages=[{"role": "user", "content": _SUMMARY_PROMPT + chunk_text}], | |
| temperature=0.0, | |
| max_tokens=150, | |
| ) | |
| return resp.choices[0].message.content.strip() | |
| except Exception as e: | |
| print(f"[db5] Summary LLM error: {e}") | |
| return chunk_text[:300] # graceful fallback | |
| # ββ FAISS archive ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _archive_sessions_to_faiss(sessions: list, username: str) -> None: | |
| for session in sessions: | |
| chunks = _make_chunks( | |
| session.get("messages", []), | |
| session_id=session.get("session_id", 0), | |
| ) | |
| if chunks: | |
| _add_chunks_to_faiss(chunks, username=username) | |
| def _add_chunks_to_faiss(chunks: list[dict], username: str) -> None: | |
| """ | |
| Build LangChain Documents from chunks (raw + summary pair per chunk) | |
| and merge into the user's db5 FAISS index in Supabase. | |
| Two documents per chunk: | |
| raw β full formatted turns text; matched by exact phrasing | |
| summary β LLM 2-3 sentence summary; matched by topic/theme | |
| Both carry `raw_text` in metadata so callers always have the full exchange | |
| regardless of which document type was retrieved. | |
| """ | |
| if not chunks: | |
| return | |
| embeddings = _get_embeddings() | |
| docs = [] | |
| for chunk in chunks: | |
| summary = _summarise_chunk(chunk["text"]) | |
| base_meta = { | |
| "chunk_id": chunk["chunk_id"], | |
| "session_id": chunk["session_id"], | |
| "turn_start": chunk["turn_start"], | |
| "turn_end": chunk["turn_end"], | |
| "timestamp_start": chunk["timestamp_start"], | |
| "timestamp_end": chunk["timestamp_end"], | |
| "character": chunk["character"], | |
| "raw_text": chunk["text"], # always present for retrieval output | |
| } | |
| docs.append(LangchainDocument( | |
| page_content=chunk["text"], | |
| metadata={**base_meta, "chunk_type": "raw"}, | |
| )) | |
| docs.append(LangchainDocument( | |
| page_content=summary, | |
| metadata={**base_meta, "chunk_type": "summary"}, | |
| )) | |
| _merge_docs_into_db5(docs, embeddings, username) | |
| print(f"[db5] Archived {len(chunks)} chunks ({len(docs)} docs) for {username}") | |
| def _merge_docs_into_db5( | |
| docs: list[LangchainDocument], | |
| embeddings: HuggingFaceEmbeddings, | |
| username: str, | |
| ) -> None: | |
| """Create a new FAISS index from docs and merge it into the existing db5.""" | |
| user_folder = f"user_{username}" | |
| db_name = "db5" | |
| bucket = "Databases" | |
| path_prefix = f"users/{user_folder}/{db_name}" | |
| with TemporaryDirectory() as tmp_dir: | |
| tmp_path = Path(tmp_dir) | |
| new_db = FAISS.from_documents(docs, embeddings) | |
| new_db.save_local(str(tmp_path)) | |
| # try to download and merge with existing db5 | |
| existing_path = tmp_path / "existing" | |
| existing_path.mkdir(exist_ok=True) | |
| has_existing = True | |
| for fname in ["index.faiss", "index.pkl"]: | |
| try: | |
| data = supabase.storage.from_(bucket).download(f"{path_prefix}/{fname}") | |
| with open(existing_path / fname, "wb") as f: | |
| f.write(data) | |
| except Exception: | |
| has_existing = False | |
| break | |
| if has_existing: | |
| try: | |
| base = FAISS.load_local( | |
| str(existing_path), embeddings, | |
| allow_dangerous_deserialization=True, | |
| ) | |
| base.merge_from(new_db) | |
| base.save_local(str(tmp_path)) | |
| except Exception as e: | |
| print(f"[db5] Merge failed, uploading fresh index: {e}") | |
| # upload (remove first to avoid 409 conflict) | |
| for fname in ["index.faiss", "index.pkl"]: | |
| local = tmp_path / fname | |
| if not local.exists(): | |
| continue | |
| remote = f"{path_prefix}/{fname}" | |
| try: | |
| supabase.storage.from_(bucket).remove([remote]) | |
| except Exception: | |
| pass | |
| with open(local, "rb") as fh: | |
| supabase.storage.from_(bucket).upload( | |
| remote, fh, | |
| {"content-type": "application/octet-stream"}, | |
| ) | |