Skip to content

Commit 0a462e1

Browse files
committed
feat(bot): add per-user conversation memory with summary and sarcastic emoji style in system prompt
1 parent b7feaf0 commit 0a462e1

4 files changed

Lines changed: 228 additions & 1 deletion

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ OneLiteFeather Discord RAG Bot (pgvector + LlamaIndex)
33

44
Overview
55
- Discord bot + queue-based worker ecosystem that talks directly to Postgres/pgvector for retrieval-based answers (no REST intermediary).
6+
- Conversation memory: per-user Kontext + kompakte Zusammenfassung wird in Postgres gespeichert und fließt in Antworten ein.
67
- Queue jobs are delivered via RabbitMQ with Postgres metadata so multiple workers can scale horizontally; an indexing CLI is still available for ad-hoc runs.
78
- Modular architecture: commands/listeners, DI services, provider abstraction (OpenAI, Ollama, vLLM) and built-in Prometheus metrics for Discord, RAG, and jobs.
89
- docker-compose includes Postgres/pgvector and optional Ollama for local end-to-end testing.
@@ -19,6 +20,7 @@ Scaling
1920
- Deploying to Kubernetes: use the provided bot and worker deployments plus the dedicated HPAs (`k8s/bot-hpa.yaml`, `k8s/worker-hpa.yaml`).
2021
- The bot exposes `/metrics`, `/healthz`, `/readyz` on `APP_HEALTH_HTTP_PORT` so k8s liveness/readiness and Prometheus scraping work.
2122
- Workers will auto-scale through RabbitMQ and the `rag-run-queue` HPA; configure RabbitMQ + Postgres as a shared queue reference.
23+
- Style: Antworten sind hilfreich mit trockenem Sarkasmus und passenden Discord‑Emojis; Emoji-/Style‑Guides können via RAG indexiert werden.
2224

2325

2426

src/discord_rag_bot/bot/startup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from rag_core.tools.registry import ToolsRegistry
1111
from ..infrastructure.config_store import ensure_store as ensure_config_store
1212
from ..infrastructure.config_store import migrate_prompts_files_to_db
13+
from ..infrastructure.memory import ensure_store as ensure_memory_store
1314

1415

1516
def build_services() -> BotServices:
@@ -43,6 +44,11 @@ def build_services() -> BotServices:
4344
migrate_prompts_files_to_db(delete_files=True)
4445
except Exception:
4546
pass
47+
# Ensure memory store
48+
try:
49+
ensure_memory_store()
50+
except Exception:
51+
pass
4652
tools = ToolsRegistry()
4753
return BotServices(rag=rag, job_repo_factory=job_repo_factory, job_repo_default=default_job_repo, tools=tools)
4854

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
from dataclasses import dataclass
5+
from typing import Optional, Sequence
6+
7+
import asyncpg
8+
9+
from ..config import settings
10+
11+
12+
def _dsn() -> str:
13+
db = settings.db
14+
return f"postgresql://{db.user}:{db.password}@{db.host}:{db.port}/{db.database}"
15+
16+
17+
async def _ensure_async(conn: asyncpg.Connection) -> None:
18+
await conn.execute(
19+
"""
20+
CREATE TABLE IF NOT EXISTS bot_memory (
21+
id BIGSERIAL PRIMARY KEY,
22+
user_id BIGINT NOT NULL,
23+
guild_id BIGINT,
24+
channel_id BIGINT,
25+
role TEXT NOT NULL, -- 'user' | 'assistant' | 'system' | 'summary'
26+
kind TEXT NOT NULL DEFAULT 'message',
27+
content TEXT NOT NULL,
28+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
29+
);
30+
CREATE INDEX IF NOT EXISTS idx_bot_memory_user_chan_time ON bot_memory(user_id, channel_id, created_at DESC);
31+
CREATE INDEX IF NOT EXISTS idx_bot_memory_user_time ON bot_memory(user_id, created_at DESC);
32+
"""
33+
)
34+
35+
36+
def ensure_store() -> None:
37+
async def run():
38+
conn = await asyncpg.connect(_dsn())
39+
try:
40+
await _ensure_async(conn)
41+
finally:
42+
await conn.close()
43+
44+
asyncio.run(run())
45+
46+
47+
def save_message(*, user_id: int, guild_id: Optional[int], channel_id: Optional[int], role: str, content: str, kind: str = "message") -> None:
48+
async def run():
49+
conn = await asyncpg.connect(_dsn())
50+
try:
51+
await _ensure_async(conn)
52+
await conn.execute(
53+
"""
54+
INSERT INTO bot_memory(user_id, guild_id, channel_id, role, kind, content)
55+
VALUES ($1, $2, $3, $4, $5, $6)
56+
""",
57+
int(user_id),
58+
int(guild_id) if guild_id is not None else None,
59+
int(channel_id) if channel_id is not None else None,
60+
role,
61+
kind,
62+
content,
63+
)
64+
finally:
65+
await conn.close()
66+
67+
asyncio.run(run())
68+
69+
70+
@dataclass
71+
class MemorySlice:
72+
summary: Optional[str]
73+
recent: list[tuple[str, str]] # list of (role, content)
74+
75+
76+
def load_slice(*, user_id: int, channel_id: Optional[int], limit: int = 8) -> MemorySlice:
77+
async def run() -> MemorySlice:
78+
conn = await asyncpg.connect(_dsn())
79+
try:
80+
await _ensure_async(conn)
81+
# summary (latest)
82+
row = await conn.fetchrow(
83+
"""
84+
SELECT content FROM bot_memory
85+
WHERE user_id=$1 AND role='summary'
86+
ORDER BY created_at DESC
87+
LIMIT 1
88+
""",
89+
int(user_id),
90+
)
91+
summary = str(row["content"]) if row and row["content"] else None
92+
93+
# recent conversation in channel (user/assistant roles)
94+
if channel_id is not None:
95+
rows: Sequence[asyncpg.Record] = await conn.fetch(
96+
"""
97+
SELECT role, content FROM bot_memory
98+
WHERE user_id=$1 AND channel_id=$2 AND role IN ('user','assistant')
99+
ORDER BY created_at DESC
100+
LIMIT $3
101+
""",
102+
int(user_id),
103+
int(channel_id),
104+
int(limit),
105+
)
106+
else:
107+
rows = await conn.fetch(
108+
"""
109+
SELECT role, content FROM bot_memory
110+
WHERE user_id=$1 AND role IN ('user','assistant')
111+
ORDER BY created_at DESC
112+
LIMIT $2
113+
""",
114+
int(user_id),
115+
int(limit),
116+
)
117+
recent = [(str(r["role"]), str(r["content"])) for r in rows]
118+
recent.reverse() # chronological
119+
return MemorySlice(summary=summary, recent=list(recent))
120+
finally:
121+
await conn.close()
122+
123+
return asyncio.run(run())
124+
125+
126+
def update_summary_with_ai(*, current_summary: Optional[str], user_text: str, bot_answer: str, answer_llm: callable) -> Optional[str]:
127+
"""Use the LLM to keep a concise user memory summary.
128+
129+
answer_llm: callable(question: str, system_prompt: Optional[str]) -> str
130+
"""
131+
sys_prompt = (
132+
"Du bist ein Assistent, der eine kurze, stichpunktartige Nutzer-Zusammenfassung pflegt.\n"
133+
"Extrahiere nur langlebige Fakten, Präferenzen, Schreibstil/Emoji-Vorlieben, Sprache, wichtige Kontexte.\n"
134+
"Halte es knapp (max. ~6 Stichpunkte), keine PII, nichts Sensibles. Aktualisiere konsistent.\n"
135+
)
136+
base = current_summary or "(leer)"
137+
question = (
138+
"Aktualisiere diese Nutzer-Zusammenfassung auf Basis der neuen Interaktion.\n\n"
139+
f"Bisherige Zusammenfassung:\n{base}\n\n"
140+
f"Neue Nachricht des Nutzers:\n{user_text}\n\n"
141+
f"Antwort des Bots:\n{bot_answer}"
142+
)
143+
try:
144+
updated = answer_llm(question, system_prompt=sys_prompt)
145+
return updated.strip()
146+
except Exception:
147+
return None
148+

src/discord_rag_bot/listeners/chat.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from ..util.text import clip_discord_message
1010
from rag_core import RagResult
1111
from ..infrastructure.config_store import load_prompt_effective
12+
from ..infrastructure.memory import save_message, load_slice, update_summary_with_ai
1213
from ..infrastructure.gating import should_use_rag
1314
from ..infrastructure.language import get_language_hint
1415
from rag_core.metrics import discord_messages_processed_total, rag_queries_total
@@ -80,8 +81,30 @@ async def on_message(self, message: discord.Message):
8081
# nothing to ask
8182
return
8283

84+
def _style_prompt(base: str | None, mem_summary: str | None, recent: list[tuple[str, str]]) -> str:
85+
style = (
86+
"Du antwortest hilfreich, prägnant und mit trockenem Sarkasmus, ohne unhöflich zu sein.\n"
87+
"Nutze passende Discord-Emojis (z. B. 😅, 🤔, ✅, ❌, 🧠, 🔧, 📎), aber nicht übermäßig.\n"
88+
"Wenn Daten fehlen, sag es ehrlich. Antworte in der Sprache des Nutzers.\n"
89+
)
90+
mem = ""
91+
if mem_summary:
92+
mem += f"\nNutzerprofil (Zusammenfassung):\n{mem_summary}\n"
93+
if recent:
94+
# Kurzer Kontext aus letzten Beiträgen
95+
lines = []
96+
for r, c in recent[-6:]:
97+
prefix = "User" if r == "user" else "Bot"
98+
lines.append(f"- {prefix}: {c[:300]}")
99+
mem += "\nLetzte Unterhaltungsschritte:\n" + "\n".join(lines) + "\n"
100+
base = base or ""
101+
return (base + "\n\n" + style + mem).strip()
102+
83103
def run_query() -> tuple[str, list[str]]:
84-
prompt = load_prompt_effective(message.guild.id if message.guild else None, message.channel.id)
104+
base_prompt = load_prompt_effective(message.guild.id if message.guild else None, message.channel.id)
105+
# Load user memory slice (summary + recent channel messages)
106+
mem = load_slice(user_id=message.author.id, channel_id=message.channel.id)
107+
prompt = _style_prompt(base_prompt, mem.summary, mem.recent)
85108
lang_hint = get_language_hint(question)
86109
if lang_hint:
87110
prompt = f"{prompt}\n\nAntwortsprache: {lang_hint}"
@@ -123,6 +146,17 @@ def run_query() -> tuple[str, list[str]]:
123146

124147
# Send friendly placeholder reply and then edit when ready
125148
placeholder_msg = await message.reply("🧠 Einen kleinen Moment – ich suche passende Informationen und schreibe die Antwort …")
149+
# Save the incoming user message into memory (best-effort)
150+
try:
151+
save_message(
152+
user_id=message.author.id,
153+
guild_id=message.guild.id if message.guild else None,
154+
channel_id=message.channel.id if hasattr(message.channel, "id") else None,
155+
role="user",
156+
content=message.content or "",
157+
)
158+
except Exception:
159+
pass
126160
answer, sources = await asyncio.to_thread(run_query)
127161
if sources:
128162
try:
@@ -138,6 +172,43 @@ def run_query() -> tuple[str, list[str]]:
138172
except Exception:
139173
# Fallback: send a fresh reply if edit fails
140174
await message.reply(clip_discord_message(text))
175+
# Save bot answer and update summary in background (best-effort)
176+
try:
177+
save_message(
178+
user_id=message.author.id,
179+
guild_id=message.guild.id if message.guild else None,
180+
channel_id=message.channel.id if hasattr(message.channel, "id") else None,
181+
role="assistant",
182+
content=text,
183+
)
184+
except Exception:
185+
pass
186+
# Summarize/update user memory asynchronously
187+
async def _update_summary_bg():
188+
try:
189+
mem_now = load_slice(user_id=message.author.id, channel_id=message.channel.id)
190+
updated = update_summary_with_ai(
191+
current_summary=mem_now.summary,
192+
user_text=message.content or "",
193+
bot_answer=text,
194+
answer_llm=lambda q, system_prompt: self.bot.services.rag.answer_llm(q, system_prompt=system_prompt), # type: ignore[attr-defined]
195+
)
196+
if updated and updated.strip():
197+
save_message(
198+
user_id=message.author.id,
199+
guild_id=message.guild.id if message.guild else None,
200+
channel_id=None,
201+
role="summary",
202+
content=updated.strip(),
203+
kind="summary",
204+
)
205+
except Exception:
206+
pass
207+
208+
try:
209+
asyncio.create_task(_update_summary_bg())
210+
except Exception:
211+
pass
141212

142213

143214
async def setup(bot: commands.Bot):

0 commit comments

Comments
 (0)