File size: 23,464 Bytes
697288c 202d151 697288c 202d151 697288c 92698bc 697288c 92698bc 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 697288c 202d151 92698bc 202d151 697288c 202d151 | 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 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 | """Smoke tests for database operations and the new memory-layer methods.
Uses a local SQLite database so no PostgreSQL or sentence_transformers
dependencies are required. The test exercises the same DatabaseManager
code paths that production uses, adapted here via a thin SQLite wrapper
that mirrors its public API.
"""
import json
import os
import sqlite3
import sys
sys.path.insert(0, os.path.dirname(__file__))
TEST_DB = "data/test_montaigne.db"
os.makedirs(os.path.dirname(TEST_DB), exist_ok=True)
if os.path.exists(TEST_DB):
os.remove(TEST_DB)
# ββ Minimal embedding stub ββββββββββββββββββββββββββββββββββββββββββββββ
def _fake_embed(text: str) -> list[float]:
"""Deterministic 8-dim vector so cosine similarity still works."""
import hashlib
h = hashlib.sha256(text.encode()).digest()
return [b / 255.0 for b in h[:8]]
# ββ SQLite-based DatabaseManager stub that mirrors the real API βββββββββ
class SQLiteDatabaseManager:
"""Thin SQLite wrapper exposing the same public methods the test uses."""
def __init__(self, db_path: str):
self._db_path = db_path
self._init_schema()
# -- connection helpers ------------------------------------------------
def _conn(self):
return sqlite3.connect(self._db_path)
# -- schema ------------------------------------------------------------
def _init_schema(self):
conn = self._conn()
c = conn.cursor()
c.executescript("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
is_guest INTEGER DEFAULT 0,
guest_ip TEXT,
is_admin INTEGER DEFAULT 0,
email TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT,
openwebui_chat_id TEXT,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
role TEXT NOT NULL CHECK(role IN ('user','assistant')),
content TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_profiles (
user_id INTEGER PRIMARY KEY REFERENCES users(id),
summary_text TEXT DEFAULT '',
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
embedding TEXT NOT NULL,
metadata TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS conversation_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES conversations(id),
user_id INTEGER NOT NULL REFERENCES users(id),
summary_text TEXT NOT NULL,
message_count INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_conv_summaries_unique_conv
ON conversation_summaries(conversation_id);
CREATE TABLE IF NOT EXISTS user_facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category TEXT NOT NULL DEFAULT 'general',
content TEXT NOT NULL,
importance REAL DEFAULT 0.5,
confidence REAL DEFAULT 0.8,
source_conv_id INTEGER REFERENCES conversations(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_reinforced TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_pinned INTEGER DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_facts_unique_content
ON user_facts(user_id, content);
""")
conn.commit()
conn.close()
# -- users -------------------------------------------------------------
def create_user(self, username: str, password_hash: str) -> int:
conn = self._conn()
c = conn.cursor()
c.execute(
"INSERT INTO users (username, password_hash) VALUES (?, ?) RETURNING id",
(username, password_hash),
)
uid = c.fetchone()[0]
c.execute(
"INSERT INTO user_profiles (user_id, summary_text) VALUES (?, '')", (uid,)
)
conn.commit()
conn.close()
return uid
def get_user_by_username(self, username: str):
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT id, username, password_hash FROM users WHERE username = ?",
(username,),
)
row = c.fetchone()
conn.close()
return (
{"id": row[0], "username": row[1], "password_hash": row[2]} if row else None
)
def verify_password(self, username: str, password: str):
from backend.auth import verify_password as _verify
user = self.get_user_by_username(username)
if user and _verify(password, user["password_hash"]):
return user["id"]
return None
# -- conversations & messages ------------------------------------------
def create_conversation(self, user_id: int) -> int:
conn = self._conn()
c = conn.cursor()
c.execute(
"INSERT INTO conversations (user_id) VALUES (?) RETURNING id", (user_id,)
)
cid = c.fetchone()[0]
conn.commit()
conn.close()
return cid
# -- OpenWebUI integration ---------------------------------------------
def get_user_by_email(self, email: str):
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT id, username, password_hash, is_guest, email FROM users WHERE email = ?",
(email,),
)
row = c.fetchone()
conn.close()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"is_guest": bool(row[3]),
"email": row[4],
}
def create_oauth_user(self, username: str, email: str = None) -> int:
conn = self._conn()
c = conn.cursor()
c.execute(
"INSERT INTO users (username, password_hash, email) VALUES (?, '', ?) RETURNING id",
(username, email),
)
uid = c.fetchone()[0]
c.execute(
"INSERT INTO user_profiles (user_id, summary_text) VALUES (?, '')", (uid,)
)
conn.commit()
conn.close()
return uid
def get_or_create_openwebui_user(self, email: str) -> int:
existing = self.get_user_by_email(email)
if existing and not existing.get("is_guest"):
return existing["id"]
username = email.split("@")[0]
if self.get_user_by_username(username):
import secrets
username = f"{username}_{secrets.token_hex(4)}"
return self.create_oauth_user(username, email)
def get_or_create_conversation_by_openwebui_chat(
self, user_id: int, openwebui_chat_id: str
) -> int:
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT id FROM conversations WHERE openwebui_chat_id = ?",
(openwebui_chat_id,),
)
row = c.fetchone()
if row:
conv_id = row[0]
else:
c.execute(
"INSERT INTO conversations (user_id, openwebui_chat_id) VALUES (?, ?) RETURNING id",
(user_id, openwebui_chat_id),
)
conv_id = c.fetchone()[0]
c.execute(
"INSERT INTO user_profiles (user_id, summary_text) VALUES (?, '') ON CONFLICT (user_id) DO NOTHING",
(user_id,),
)
conn.commit()
conn.close()
return conv_id
def add_message(self, conversation_id: int, role: str, content: str) -> int:
conn = self._conn()
c = conn.cursor()
c.execute(
"INSERT INTO messages (conversation_id, role, content) VALUES (?, ?, ?) RETURNING id",
(conversation_id, role, content),
)
mid = c.fetchone()[0]
conn.commit()
conn.close()
return mid
def get_conversation_messages(self, conversation_id: int, limit: int = 50):
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT role, content, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC LIMIT ?",
(conversation_id, limit),
)
rows = c.fetchall()
conn.close()
return [{"role": r[0], "content": r[1], "timestamp": str(r[2])} for r in rows]
def get_recent_messages(self, user_id: int, limit: int = 10):
conn = self._conn()
c = conn.cursor()
c.execute(
"""SELECT m.role, m.content, m.timestamp
FROM messages m JOIN conversations c ON m.conversation_id = c.id
WHERE c.user_id = ? ORDER BY m.timestamp DESC LIMIT ?""",
(user_id, limit),
)
rows = c.fetchall()
conn.close()
return [
{"role": r[0], "content": r[1], "timestamp": str(r[2])}
for r in reversed(rows)
]
def count_conversation_messages(self, conversation_id: int) -> int:
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT COUNT(*) FROM messages WHERE conversation_id = ?",
(conversation_id,),
)
count = c.fetchone()[0]
conn.close()
return count
def get_conversation_turns(
self, conversation_id: int, limit: int = 20, offset_from_end: int = 0
):
conn = self._conn()
c = conn.cursor()
c.execute(
"""SELECT role, content, timestamp, id FROM messages
WHERE conversation_id = ? ORDER BY id DESC LIMIT ? OFFSET ?""",
(conversation_id, limit, offset_from_end),
)
rows = c.fetchall()
conn.close()
return [
{"role": r[0], "content": r[1], "timestamp": str(r[2]), "id": r[3]}
for r in reversed(rows)
]
# -- profile -----------------------------------------------------------
def get_user_profile(self, user_id: int) -> str:
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT summary_text FROM user_profiles WHERE user_id = ?", (user_id,)
)
row = c.fetchone()
conn.close()
return row[0] if row else ""
def update_user_profile(self, user_id: int, summary_text: str):
conn = self._conn()
c = conn.cursor()
c.execute(
"UPDATE user_profiles SET summary_text = ?, last_updated = CURRENT_TIMESTAMP WHERE user_id = ?",
(summary_text, user_id),
)
conn.commit()
conn.close()
# -- semantic memory ---------------------------------------------------
def add_memory(self, user_id: int, text: str, metadata=None):
emb = json.dumps(_fake_embed(text))
meta = json.dumps(metadata or {})
conn = self._conn()
c = conn.cursor()
c.execute(
"INSERT INTO memories (user_id, content, embedding, metadata) VALUES (?, ?, ?, ?)",
(user_id, text, emb, meta),
)
conn.commit()
conn.close()
def search_memories(self, user_id: int, query: str, n_results: int = 5):
import numpy as np
query_vec = np.array(_fake_embed(query))
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT content, embedding, metadata FROM memories WHERE user_id = ?",
(user_id,),
)
rows = c.fetchall()
conn.close()
scored = []
for content, emb_json, meta_json in rows:
vec = np.array(json.loads(emb_json))
sim = float(
np.dot(query_vec, vec)
/ (np.linalg.norm(query_vec) * np.linalg.norm(vec))
)
scored.append(
{
"content": content,
"metadata": json.loads(meta_json),
"similarity": sim,
}
)
scored.sort(key=lambda x: x["similarity"], reverse=True)
return [m for m in scored[:n_results] if m["similarity"] > 0.3]
# -- episodic memory (conversation summaries) --------------------------
def get_conversation_summary(self, conversation_id: int):
conn = self._conn()
c = conn.cursor()
c.execute(
"SELECT summary_text FROM conversation_summaries WHERE conversation_id = ? ORDER BY updated_at DESC LIMIT 1",
(conversation_id,),
)
row = c.fetchone()
conn.close()
return row[0] if row else None
def save_conversation_summary(
self,
conversation_id: int,
user_id: int,
summary_text: str,
message_count: int = 0,
):
conn = self._conn()
c = conn.cursor()
c.execute(
"""INSERT INTO conversation_summaries (conversation_id, user_id, summary_text, message_count)
VALUES (?, ?, ?, ?)
ON CONFLICT (conversation_id) DO UPDATE SET
summary_text = excluded.summary_text,
message_count = excluded.message_count,
updated_at = CURRENT_TIMESTAMP""",
(conversation_id, user_id, summary_text, message_count),
)
conn.commit()
conn.close()
# -- semantic memory (user facts) --------------------------------------
def add_user_fact(
self,
user_id: int,
content: str,
category: str = "general",
importance: float = 0.5,
source_conv_id=None,
):
conn = self._conn()
c = conn.cursor()
c.execute(
"""INSERT INTO user_facts (user_id, category, content, importance, source_conv_id)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT (user_id, content) DO UPDATE SET
category = excluded.category,
importance = MAX(user_facts.importance, excluded.importance),
last_reinforced = CURRENT_TIMESTAMP""",
(user_id, category, content, importance, source_conv_id),
)
conn.commit()
conn.close()
def get_user_facts(self, user_id: int, limit: int = 8, category=None):
conn = self._conn()
c = conn.cursor()
if category:
c.execute(
"SELECT category, content, importance, is_pinned FROM user_facts WHERE user_id = ? AND category = ? "
"ORDER BY is_pinned DESC, importance DESC, last_reinforced DESC LIMIT ?",
(user_id, category, limit),
)
else:
c.execute(
"SELECT category, content, importance, is_pinned FROM user_facts WHERE user_id = ? "
"ORDER BY is_pinned DESC, importance DESC, last_reinforced DESC LIMIT ?",
(user_id, limit),
)
rows = c.fetchall()
conn.close()
return [
{"category": r[0], "content": r[1], "importance": r[2], "is_pinned": r[3]}
for r in rows
]
def delete_user_fact(self, user_id: int, content: str) -> bool:
conn = self._conn()
c = conn.cursor()
c.execute(
"DELETE FROM user_facts WHERE user_id = ? AND content = ?",
(user_id, content),
)
deleted = c.rowcount > 0
conn.commit()
conn.close()
return deleted
# -- guest / cleanup (stubs for test) ----------------------------------
def create_guest_user(self, ip: str) -> int:
return 0
def cleanup_expired_guests(self, hours: int = 48):
pass
def close(self):
pass
# ββ Test runner βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def run():
print("=== DB smoke-test ===\n")
db = SQLiteDatabaseManager(TEST_DB)
# 1. Users & auth
from backend.auth import get_password_hash, verify_password as auth_verify
pw_hash = get_password_hash("hello123")
uid = db.create_user("testuser", pw_hash)
assert uid > 0
print(f"[OK] created user id={uid}")
assert auth_verify("hello123", pw_hash), "password verify failed"
assert not auth_verify("wrong", pw_hash), "wrong password should fail"
print("[OK] password hashing + verification")
verified_id = db.verify_password("testuser", "hello123")
assert verified_id == uid
print(f"[OK] db.verify_password -> {verified_id}")
# 2. Conversations & messages
cid = db.create_conversation(uid)
assert cid > 0
print(f"[OK] created conversation id={cid}")
db.add_message(cid, "user", "Hello Montaigne!")
db.add_message(cid, "assistant", "Ah, welcome, friend.")
msgs = db.get_conversation_messages(cid)
assert len(msgs) == 2
print(f"[OK] messages stored/retrieved ({len(msgs)} messages)")
# 3. Working memory β get_recent_messages (backward compat)
recent = db.get_recent_messages(uid)
assert len(recent) == 2
print(f"[OK] recent messages across conversations ({len(recent)} messages)")
# 4. Working memory β conversation-scoped turns
turns = db.get_conversation_turns(cid)
assert len(turns) == 2
assert turns[0]["content"] == "Hello Montaigne!"
print(f"[OK] conversation-scoped turns ({len(turns)} turns)")
# 5. Profile
db.update_user_profile(uid, "Interested in Stoicism and friendship.")
profile = db.get_user_profile(uid)
assert "Stoicism" in profile
print(f"[OK] user profile: {profile!r}")
# 6. Semantic memory (legacy)
db.add_memory(uid, "The user finds joy in long walks and reading Plutarch.")
db.add_memory(uid, "The user dislikes crowded places and noise.")
results = db.search_memories(uid, "Does the user enjoy nature?")
assert len(results) > 0, "expected at least one memory hit"
print(f"[OK] semantic search returned {len(results)} result(s)")
print(
f" top hit (sim={results[0]['similarity']:.3f}): {results[0]['content'][:60]}"
)
# 7. User facts (new semantic memory)
db.add_user_fact(
uid,
"Likes Stoicism.",
category="preference",
importance=0.8,
source_conv_id=cid,
)
db.add_user_fact(
uid, "Name is Marcus.", category="name", importance=0.9, source_conv_id=cid
)
db.add_user_fact(
uid, "Enjoys long walks.", category="habit", importance=0.5, source_conv_id=cid
)
facts = db.get_user_facts(uid)
assert len(facts) == 3
assert facts[0]["category"] == "name", "facts should be sorted by importance DESC"
assert facts[1]["category"] == "preference"
print(f"[OK] user facts ({len(facts)} stored, sorted by importance)")
# 7a. Deduplication β same content should reinforce, not duplicate
db.add_user_fact(
uid,
"Likes Stoicism.",
category="preference",
importance=0.9,
source_conv_id=cid,
)
facts_after = db.get_user_facts(uid)
assert len(facts_after) == 3, "duplicate fact should not create a new row"
stoicism = next(f for f in facts_after if f["content"] == "Likes Stoicism.")
assert stoicism["importance"] == 0.9, "reinforcement should use MAX(importance)"
print("[OK] user facts deduplication (ON CONFLICT update)")
# 7b. Delete
deleted = db.delete_user_fact(uid, "Enjoys long walks.")
assert deleted
assert len(db.get_user_facts(uid)) == 2
print("[OK] user fact deletion")
# 8. Conversation summary (episodic memory)
assert db.get_conversation_summary(cid) is None
db.save_conversation_summary(
cid, uid, "Discussing friendship and doubts about identity.", message_count=4
)
summary = db.get_conversation_summary(cid)
assert summary is not None
assert "friendship" in summary
print(f"[OK] conversation summary: {summary[:60]}")
# 8a. Upsert β update should not create a duplicate
db.save_conversation_summary(
cid, uid, "Extended to also cover the nature of death.", message_count=8
)
summary2 = db.get_conversation_summary(cid)
assert "death" in summary2
print("[OK] conversation summary upsert (ON CONFLICT update)")
# 9. Verify context assembly inputs
turns = db.get_conversation_turns(cid)
facts = db.get_user_facts(uid, limit=3)
summary = db.get_conversation_summary(cid)
profile = db.get_user_profile(uid)
assert len(turns) == 2
assert len(facts) == 2
assert summary is not None
assert "Stoicism" in profile
print("[OK] all memory layers return data for context assembly")
# 10. OpenWebUI integration
ow_uid = db.get_or_create_openwebui_user("testuser@example.com")
assert ow_uid > 0
print(f"[OK] OpenWebUI user auto-provisioned id={ow_uid}")
# 10a. Same email returns same user
ow_uid2 = db.get_or_create_openwebui_user("testuser@example.com")
assert ow_uid2 == ow_uid, "same email should return same user"
print("[OK] OpenWebUI user deduplication by email")
# 10b. Conversation mapping
ow_cid = db.get_or_create_conversation_by_openwebui_chat(ow_uid, "chat-abc-123")
assert ow_cid > 0
print(f"[OK] OpenWebUI conversation mapped id={ow_cid}")
# 10c. Same chat ID returns same conversation
ow_cid2 = db.get_or_create_conversation_by_openwebui_chat(ow_uid, "chat-abc-123")
assert ow_cid2 == ow_cid, "same chat ID should return same conversation"
print("[OK] OpenWebUI conversation deduplication by chat ID")
# 10d. Messages work in mapped conversation
db.add_message(ow_cid, "user", "What is friendship?")
db.add_message(ow_cid, "assistant", "A single soul dwelling in two bodies.")
ow_turns = db.get_conversation_turns(ow_cid)
assert len(ow_turns) == 2
print(f"[OK] messages in OpenWebUI-mapped conversation ({len(ow_turns)} turns)")
db.close()
print("\n=== All tests passed ===")
if __name__ == "__main__":
run()
|