| """ |
| core/memorize.py |
| Aiko's persistent memory β custom backend via sqlite-vec + fastembed + Modal LLM. |
| Abstracts all memory calls so think.py stays clean. |
| |
| Memory lifecycle: |
| - Every search() call increments access_count and updates last_accessed_at |
| in the memories table, enabling Ebbinghaus-style exponential decay scoring. |
| - cleanup() deletes memories below decay threshold, with grace period |
| protection for newly created entries. |
| - Decay logic lives in core/forget.py (pure math, no I/O). |
| - Pinned memories (created via pin()) are permanently immune to decay |
| cleanup. The pinned flag lives in the memories table. |
| |
| Storage layout (single .db file): |
| memories β canonical record: id, user_id, memory, metadata |
| memories_fts β FTS5 virtual table for lexical search (BM25) |
| memories_vec β vec0 virtual table for KNN cosine search |
| |
| Recall strategy β Reciprocal Rank Fusion (RRF): |
| score = 1/(k + rank_knn) + 1/(k + rank_fts) |
| k=60 (standard RRF constant β dampens outlier ranks) |
| |
| KNN catches semantic similarity ("I love cats" β "I adore cats") |
| FTS5 catches exact token matches ("Max", "birthday", proper nouns) |
| RRF fuses both without weighting either arbitrarily. |
| |
| Custom backend (replaces Qdrant + mem0): |
| - _MemoryBackend handles LLM-based fact extraction, fastembed embeddings, |
| and direct sqlite-vec upsert/search/delete/scroll. |
| - Extraction prompt is tuned for small models: asks for a JSON array of |
| atomic facts, strips <think> blocks for CoT models, skips trivial turns. |
| - All schema fields (memory, user_id, created_at, access_count, |
| last_accessed_at, pinned) are owned by this module β no hidden schema. |
| |
| Extraction LLM: |
| - Uses LLAMA_BASE_URL (Modal OpenAI-compat endpoint) + LLAMA_API_KEY. |
| |
| Dependencies: |
| pip install sqlite-vec fastembed |
| """ |
| from dotenv import load_dotenv |
| load_dotenv() |
|
|
| import json |
| import os |
| import re |
| import sqlite3 |
| import struct |
| import time |
| import uuid |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Optional |
|
|
| import httpx |
| import sqlite_vec |
| from fastembed import TextEmbedding |
|
|
| from core.forget import compute_weighted_score, should_cleanup, CLEANUP_THRESHOLD |
| from core.log import get_logger |
|
|
| log = get_logger(__name__) |
|
|
| |
|
|
| BOOT_LABELS = { |
| 'mem_sqlite_vec': 'Opening sqlite-vec memory store...', |
| 'mem_embed': 'Loading fastembed model...', |
| 'mem_cleanup': 'Running memory cleanup...', |
| 'mem_ready': 'Memory backend ready', |
| } |
|
|
| |
|
|
| EMBED_MODEL = "BAAI/bge-base-en-v1.5" |
| EMBED_DIMS = 768 |
| RRF_K = 60 |
| KNN_LIMIT = 20 |
| FTS_LIMIT = 20 |
|
|
| USER_ID = os.getenv("USER_ID", "Guest") |
|
|
| |
| |
| _EXTRACT_MIN_CHARS = int(os.getenv("MEMORY_EXTRACT_MIN_CHARS", 80)) |
|
|
| |
| |
| |
| _EXTRACT_PROMPT = """\ |
| Extract memorable facts about the USER from this conversation. |
| The USER is {user_id}. The ASSISTANT is Aiko. |
| Write every fact from Aiko's perspective, using second-person for the user. |
| Example format: "Oppa's birthday is June 3, 2026" "Oppa created you (Aiko) recently" |
| Return ONLY a JSON array of short strings. Each string is one atomic fact. |
| Facts should be about the user's preferences, identity, life, or goals. |
| If nothing is worth remembering, return: [] |
| Do NOT include facts about Aiko's own behavior or feelings. |
| Do NOT explain. No markdown. |
| |
| Conversation: |
| {conversation}""" |
|
|
|
|
| def _sanitize_fts_query(query: str) -> str: |
| """ |
| Strip characters that break FTS5 query parsing. |
| FTS5 treats , " ( ) * ^ : - ' as syntax tokens β remove them all. |
| """ |
| cleaned = re.sub(r'[^\w\s]', ' ', query) |
| cleaned = ' '.join(cleaned.split()) |
| return cleaned or "*" |
|
|
|
|
| |
|
|
| _DDL = """ |
| PRAGMA journal_mode = WAL; |
| PRAGMA foreign_keys = ON; |
| |
| CREATE TABLE IF NOT EXISTS memories ( |
| id TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL, |
| memory TEXT NOT NULL, |
| created_at TEXT NOT NULL, |
| access_count INTEGER NOT NULL DEFAULT 0, |
| last_accessed_at TEXT NOT NULL DEFAULT 'never', |
| pinned INTEGER NOT NULL DEFAULT 0 |
| ); |
| |
| CREATE INDEX IF NOT EXISTS idx_memories_user ON memories(user_id); |
| |
| CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( |
| memory, |
| id UNINDEXED, |
| content='memories', |
| content_rowid='rowid' |
| ); |
| |
| CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0( |
| id TEXT PRIMARY KEY, |
| embedding FLOAT[{dims}] |
| ); |
| |
| CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN |
| INSERT INTO memories_fts(rowid, memory, id) |
| VALUES (new.rowid, new.memory, new.id); |
| END; |
| |
| CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN |
| INSERT INTO memories_fts(memories_fts, rowid, memory, id) |
| VALUES ('delete', old.rowid, old.memory, old.id); |
| END; |
| |
| CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE OF memory ON memories BEGIN |
| INSERT INTO memories_fts(memories_fts, rowid, memory, id) |
| VALUES ('delete', old.rowid, old.memory, old.id); |
| INSERT INTO memories_fts(rowid, memory, id) |
| VALUES (new.rowid, new.memory, new.id); |
| END; |
| """.format(dims=EMBED_DIMS) |
|
|
|
|
| |
|
|
| def _sqlite_get_payload(conn: sqlite3.Connection, mem_id: str) -> dict: |
| conn.row_factory = sqlite3.Row |
| row = conn.execute( |
| "SELECT * FROM memories WHERE id = ?", (mem_id,) |
| ).fetchone() |
| return dict(row) if row else {} |
|
|
|
|
| def _sqlite_set_payload(conn: sqlite3.Connection, mem_id: str, payload: dict) -> None: |
| if not payload: |
| return |
| cols = ", ".join(f"{k} = ?" for k in payload) |
| vals = list(payload.values()) + [mem_id] |
| conn.execute(f"UPDATE memories SET {cols} WHERE id = ?", vals) |
| conn.commit() |
|
|
|
|
| def _sqlite_batch_get_payloads(conn: sqlite3.Connection, mem_ids: list[str]) -> dict: |
| if not mem_ids: |
| return {} |
| conn.row_factory = sqlite3.Row |
| placeholders = ",".join("?" * len(mem_ids)) |
| rows = conn.execute( |
| f"SELECT id, access_count, last_accessed_at FROM memories WHERE id IN ({placeholders})", |
| mem_ids, |
| ).fetchall() |
| return { |
| r["id"]: (r["access_count"] or 0, r["last_accessed_at"] or "never") |
| for r in rows |
| } |
|
|
|
|
| def _sqlite_is_pinned(conn: sqlite3.Connection, mem_id: str) -> bool: |
| row = conn.execute( |
| "SELECT pinned FROM memories WHERE id = ?", (mem_id,) |
| ).fetchone() |
| return bool(row and row[0]) |
|
|
|
|
| def _sqlite_knn_search( |
| conn: sqlite3.Connection, |
| vector: list[float], |
| user_id: str, |
| limit: int, |
| ) -> list[sqlite3.Row]: |
| vec_blob = sqlite_vec.serialize_float32(vector) |
| rows = conn.execute( |
| """ |
| SELECT v.id, vec_distance_cosine(v.embedding, ?) AS dist |
| FROM memories_vec v |
| JOIN memories m ON m.id = v.id |
| WHERE m.user_id = ? |
| ORDER BY dist ASC |
| LIMIT ? |
| """, |
| (vec_blob, user_id, limit), |
| ).fetchall() |
| return rows |
|
|
|
|
| |
|
|
| def _call_extraction_llm(prompt: str, base_url: str, api_key: str) -> str: |
| """ |
| Send the extraction prompt to the Modal OpenAI-compat endpoint. |
| Raises on failure β caller catches and returns []. |
| """ |
| headers = {"Content-Type": "application/json"} |
| if api_key: |
| headers["Authorization"] = f"Bearer {api_key}" |
|
|
| resp = httpx.post( |
| base_url.rstrip('/'), |
| headers=headers, |
| json={ |
| "messages": [{"role": "user", "content": prompt}], |
| "stream": False, |
| "temperature": 0.1, |
| "max_tokens": 512, |
| }, |
| timeout=45, |
| ) |
| resp.raise_for_status() |
| return resp.json()["choices"][0]["message"]["content"].strip() |
|
|
|
|
| |
|
|
| class _MemoryBackend: |
| """ |
| sqlite-vec + FTS5 + RRF backend. |
| Public API: add(), search(), get_all(), delete(), delete_all() |
| """ |
|
|
| def __init__( |
| self, |
| db_path: str, |
| llama_base_url: str, |
| llama_api_key: str, |
| fastembed_cache: Optional[str] = None, |
| ) -> None: |
| Path(db_path).parent.mkdir(parents=True, exist_ok=True) |
| self._db_path = db_path |
| self._llama_base_url = llama_base_url |
| self._llama_api_key = llama_api_key |
| self._embedder = TextEmbedding( |
| model_name=EMBED_MODEL, |
| cache_dir=fastembed_cache, |
| ) |
| self._conn = self._connect() |
| self._apply_schema() |
|
|
| def _connect(self) -> sqlite3.Connection: |
| conn = sqlite3.connect(self._db_path, check_same_thread=False) |
| conn.row_factory = sqlite3.Row |
| sqlite_vec.load(conn) |
| return conn |
|
|
| def _apply_schema(self) -> None: |
| self._conn.executescript(_DDL) |
| self._conn.commit() |
|
|
| def _embed(self, text: str) -> list[float]: |
| return list(self._embedder.embed([text]))[0].tolist() |
|
|
| |
|
|
| def _should_extract(self, messages: list[dict]) -> bool: |
| total = sum( |
| len(m.get("content") or "") |
| for m in messages |
| if m.get("role") in ("user", "assistant") |
| and (m.get("content") or "").strip() |
| ) |
| return total >= _EXTRACT_MIN_CHARS |
|
|
| def _extract_facts(self, messages: list[dict], user_id: str) -> list[str]: |
| if not self._should_extract(messages): |
| return [] |
|
|
| clean_messages = [ |
| m for m in messages |
| if m.get("role") in ("user", "assistant") |
| and (m.get("content") or "").strip() |
| ] |
|
|
| while clean_messages and clean_messages[0].get("role") != "user": |
| clean_messages.pop(0) |
| while len(clean_messages) > 1 and clean_messages[-1].get("role") == "assistant": |
| if any(m.get("role") == "user" for m in clean_messages[:-1]): |
| break |
| clean_messages.pop() |
|
|
| if not clean_messages: |
| return [] |
|
|
| total = sum(len(m.get("content") or "") for m in clean_messages) |
| if total < _EXTRACT_MIN_CHARS: |
| return [] |
|
|
| convo = "\n".join( |
| f"{m['role'].upper()}: {m['content'].strip()}" |
| for m in clean_messages |
| ) |
| prompt = _EXTRACT_PROMPT.format(user_id=user_id, conversation=convo) |
|
|
| try: |
| raw = _call_extraction_llm( |
| prompt=prompt, |
| base_url=self._llama_base_url, |
| api_key=self._llama_api_key, |
| ) |
| except Exception as e: |
| log.warning("Extraction LLM call failed: %s", e) |
| return [] |
|
|
| raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() |
| raw = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.MULTILINE).strip() |
|
|
| try: |
| facts = json.loads(raw) |
| if isinstance(facts, list): |
| return [f.strip() for f in facts if isinstance(f, str) and f.strip()] |
| except json.JSONDecodeError: |
| log.warning("Failed to parse extraction JSON: %r", raw[:200]) |
|
|
| return [] |
|
|
| |
|
|
| def add(self, messages: list[dict], user_id: str) -> list[str]: |
| facts = self._extract_facts(messages, user_id=user_id) |
| if not facts: |
| return [] |
|
|
| now = datetime.now(timezone.utc).isoformat() |
| ids = [] |
|
|
| for fact in facts: |
| mem_id = str(uuid.uuid4()) |
| try: |
| vector = self._embed(fact) |
| self._conn.execute( |
| """ |
| INSERT INTO memories |
| (id, user_id, memory, created_at, access_count, last_accessed_at, pinned) |
| VALUES (?, ?, ?, ?, 0, 'never', 0) |
| """, |
| (mem_id, user_id, fact, now), |
| ) |
| self._conn.execute( |
| "INSERT INTO memories_vec(id, embedding) VALUES (?, ?)", |
| (mem_id, sqlite_vec.serialize_float32(vector)), |
| ) |
| self._conn.commit() |
| ids.append(mem_id) |
| except Exception as e: |
| log.warning("Failed to upsert fact %r: %s", mem_id, e) |
| self._conn.rollback() |
|
|
| return ids |
|
|
| |
|
|
| def search(self, query: str, user_id: str, limit: int = 5) -> list[dict]: |
| vector = self._embed(query) |
| knn_rows = _sqlite_knn_search(self._conn, vector, user_id, KNN_LIMIT) |
| rank_knn = {row["id"]: i + 1 for i, row in enumerate(knn_rows)} |
|
|
| fts_rows = self._conn.execute( |
| """ |
| SELECT f.id |
| FROM memories_fts f |
| JOIN memories m ON m.id = f.id |
| WHERE memories_fts MATCH ? |
| AND m.user_id = ? |
| ORDER BY rank |
| LIMIT ? |
| """, |
| (_sanitize_fts_query(query), user_id, FTS_LIMIT), |
| ).fetchall() |
| rank_fts = {row["id"]: i + 1 for i, row in enumerate(fts_rows)} |
|
|
| all_ids = set(rank_knn) | set(rank_fts) |
| if not all_ids: |
| return [] |
|
|
| def rrf(mem_id: str) -> float: |
| score = 0.0 |
| if mem_id in rank_knn: |
| score += 1.0 / (RRF_K + rank_knn[mem_id]) |
| if mem_id in rank_fts: |
| score += 1.0 / (RRF_K + rank_fts[mem_id]) |
| return score |
|
|
| ranked = sorted(all_ids, key=rrf, reverse=True)[:limit] |
| placeholders = ",".join("?" * len(ranked)) |
| rows = self._conn.execute( |
| f"SELECT * FROM memories WHERE id IN ({placeholders})", ranked |
| ).fetchall() |
|
|
| order = {mid: i for i, mid in enumerate(ranked)} |
| rows_sorted = sorted(rows, key=lambda r: order.get(r["id"], 999)) |
| return [dict(r) for r in rows_sorted] |
|
|
| def get_all(self, user_id: str) -> list[dict]: |
| rows = self._conn.execute( |
| "SELECT * FROM memories WHERE user_id = ?", (user_id,) |
| ).fetchall() |
| return [dict(r) for r in rows] |
|
|
| def delete(self, memory_id: str) -> None: |
| self._conn.execute("DELETE FROM memories WHERE id = ?", (memory_id,)) |
| self._conn.execute("DELETE FROM memories_vec WHERE id = ?", (memory_id,)) |
| self._conn.commit() |
|
|
| def delete_all(self, user_id: str) -> None: |
| ids = [ |
| r["id"] for r in self._conn.execute( |
| "SELECT id FROM memories WHERE user_id = ?", (user_id,) |
| ).fetchall() |
| ] |
| if not ids: |
| return |
| placeholders = ",".join("?" * len(ids)) |
| self._conn.execute(f"DELETE FROM memories WHERE id IN ({placeholders})", ids) |
| self._conn.execute(f"DELETE FROM memories_vec WHERE id IN ({placeholders})", ids) |
| self._conn.commit() |
|
|
|
|
| |
|
|
| class AikoMemorize: |
| """ |
| Persistent memory with Ebbinghaus decay lifecycle. |
| |
| Uses _MemoryBackend (LLM extraction + fastembed + sqlite-vec). |
| |
| Env vars: |
| LLAMA_BASE_URL β Modal OpenAI-compat endpoint |
| LLAMA_API_KEY β Modal API key (optional) |
| SQLITE_MEMORY_PATH β path to .db file (default: ~/.aiko/memory.db) |
| Point this at a persistent volume on HF Space. |
| FASTEMBED_CACHE_PATH β optional cache dir for fastembed model weights |
| |
| Boot sequence (called by wakeup.py): |
| memorize = AikoMemorize() |
| memorize.cleanup() |
| |
| Pinned memories are immune to cleanup() regardless of decay score. |
| """ |
|
|
| def __init__(self, silent: bool = False) -> None: |
| db_path = os.getenv( |
| "SQLITE_MEMORY_PATH", |
| str(Path.home() / ".aiko" / "memory.db"), |
| ) |
|
|
| if not silent: |
| log.info("Opening sqlite-vec memory store...") |
|
|
| self._mem = _MemoryBackend( |
| db_path=db_path, |
| llama_base_url=os.getenv("LLAMA_BASE_URL", ""), |
| llama_api_key=os.getenv("LLAMA_API_KEY", ""), |
| fastembed_cache=os.getenv("FASTEMBED_CACHE_PATH"), |
| ) |
| self._conn = self._mem._conn |
|
|
| if not silent: |
| log.info("Ready.") |
|
|
| |
|
|
| def add(self, messages: list[dict], user_id: str = USER_ID) -> bool: |
| """ |
| Extract facts from a conversation turn and persist to memory. |
| Returns True on success, False on failure. |
| """ |
| try: |
| t = time.perf_counter() |
| ids = self._mem.add(messages, user_id=user_id) |
| elapsed = time.perf_counter() - t |
| if ids: |
| log.info("Saved %d memories in %.2fs", len(ids), elapsed) |
| else: |
| log.debug("No facts extracted (%.2fs) β nothing saved.", elapsed) |
| return True |
| except Exception as e: |
| log.error("Save failed: %s", e) |
| return False |
|
|
| def pin(self, messages: list[dict], user_id: str = USER_ID) -> bool: |
| """ |
| Store messages and mark all resulting memories as pinned. |
| Pinned memories are immune to cleanup() regardless of decay score. |
| """ |
| try: |
| before = {str(m["id"]) for m in self.get_all(user_id=user_id)} |
| ok = self.add(messages, user_id=user_id) |
| if not ok: |
| return False |
| after = {str(m["id"]) for m in self.get_all(user_id=user_id)} |
| pin_ids = list(after - before) |
|
|
| if not pin_ids: |
| query = "\n".join( |
| (m.get("content") or "").strip() |
| for m in messages |
| if (m.get("content") or "").strip() |
| ) |
| pin_ids = [ |
| str(m.get("id")) |
| for m in self.search(query, user_id=user_id, limit=3) |
| if m.get("id") |
| ] |
|
|
| if not pin_ids: |
| log.warning("pin(): add succeeded but no memory IDs found to pin.") |
| return False |
|
|
| for mem_id in pin_ids: |
| _sqlite_set_payload(self._conn, mem_id, {"pinned": 1}) |
|
|
| log.info("Pinned %d memories: %s", len(pin_ids), pin_ids) |
| return True |
| except Exception as e: |
| log.error("Pin failed: %s", e) |
| return False |
|
|
| |
|
|
| def search(self, query: str, user_id: str = USER_ID, limit: int = 5) -> list[dict]: |
| """ |
| Retrieve top-k memories relevant to query via KNN + FTS5 RRF fusion. |
| Side-effect: increments access_count and updates last_accessed_at. |
| """ |
| results = self._mem.search(query, user_id=user_id, limit=limit) |
|
|
| if results: |
| now = datetime.now(timezone.utc).isoformat() |
| for r in results: |
| mem_id = str(r.get("id", "")) |
| if not mem_id: |
| continue |
| try: |
| payload = _sqlite_get_payload(self._conn, mem_id) |
| current_count = payload.get("access_count", 0) or 0 |
| _sqlite_set_payload(self._conn, mem_id, { |
| "access_count": min(current_count + 1, 255), |
| "last_accessed_at": now, |
| }) |
| except Exception as e: |
| log.warning("Access tracking failed for %s: %s", mem_id, e) |
|
|
| return results |
|
|
| def format_for_context(self, memories: list[dict]) -> Optional[str]: |
| """ |
| Format retrieved memories into a string for injection into system prompt. |
| Returns None if nothing to inject. |
| """ |
| if not memories: |
| return None |
|
|
| now = datetime.now(timezone.utc) |
| lines = [ |
| "<memory_context>", |
| "The following are background facts about this person, with how long ago they were recorded.", |
| "Use them silently to inform your response. Never repeat, quote, or reference this block directly.", |
| "", |
| ] |
| for m in memories: |
| text = m.get("memory") or m.get("text") or str(m) |
| created_at = m.get("created_at") |
| if created_at: |
| try: |
| ts = datetime.fromisoformat(created_at.replace("Z", "+00:00")) |
| delta = now - ts |
| days = delta.days |
| if days == 0: |
| age = "today" |
| elif days == 1: |
| age = "yesterday" |
| else: |
| age = f"{days} days ago" |
| lines.append(f" - [{age}] {text}") |
| except Exception: |
| lines.append(f" - {text}") |
| else: |
| lines.append(f" - {text}") |
|
|
| lines.append("</memory_context>") |
| return "\n".join(lines) |
|
|
| |
|
|
| def cleanup( |
| self, |
| user_id: str = USER_ID, |
| threshold: float = CLEANUP_THRESHOLD, |
| dry_run: bool = False, |
| ) -> dict: |
| """ |
| Prune decayed memories below threshold score. |
| Grace period (14 days) protects newly created memories. |
| Pinned memories are unconditionally kept. |
| Returns dict: {deleted, kept, failed} |
| """ |
| all_mems = self.get_all(user_id=user_id) |
| if not all_mems: |
| return {"deleted": 0, "kept": 0, "failed": 0} |
|
|
| mem_ids = [str(m.get("id", "")) for m in all_mems if m.get("id")] |
| payload_map = _sqlite_batch_get_payloads(self._conn, mem_ids) |
|
|
| candidates = [] |
| kept = 0 |
|
|
| for m in all_mems: |
| mem_id = str(m.get("id", "")) |
| ac, la = payload_map.get(mem_id, (0, "never")) |
| created_at = m.get("created_at", "") |
|
|
| if _sqlite_is_pinned(self._conn, mem_id): |
| kept += 1 |
| continue |
|
|
| if should_cleanup(ac, la, created_at): |
| candidates.append({ |
| "id": mem_id, |
| "weighted_score": round(compute_weighted_score(ac, la), 4), |
| }) |
| else: |
| kept += 1 |
|
|
| candidates.sort(key=lambda x: x["weighted_score"]) |
|
|
| if dry_run: |
| log.info("Dry run: %d candidates for deletion, %d kept.", len(candidates), kept) |
| return {"deleted": 0, "kept": kept, "failed": 0, "candidates": candidates} |
|
|
| deleted = 0 |
| failed = 0 |
| for c in candidates: |
| try: |
| self._mem.delete(memory_id=c["id"]) |
| deleted += 1 |
| except Exception as e: |
| log.warning("Cleanup delete failed for %s: %s", c["id"], e) |
| failed += 1 |
|
|
| log.info("Cleanup: deleted=%d, kept=%d, failed=%d", deleted, kept, failed) |
| return {"deleted": deleted, "kept": kept, "failed": failed} |
|
|
| |
|
|
| def get_all(self, user_id: str = USER_ID) -> list[dict]: |
| """Return all stored memories for a user.""" |
| return self._mem.get_all(user_id=user_id) |
|
|
| def clear(self, user_id: str = USER_ID) -> None: |
| """Wipe all memories for a user. Use carefully.""" |
| self._mem.delete_all(user_id=user_id) |
| log.info("Cleared all memories for user '%s'.", user_id) |