feat: knowledge-base memory, Dockerfile, docker-compose, CI/release workflows, PR template
This commit is contained in:
committed by
GitHub
parent
c74c8b0d8b
commit
867542dd49
+82
-7
@@ -39,6 +39,20 @@ _FLUSH_SYSTEM_PROMPT = (
|
||||
"Be precise. Omit pleasantries."
|
||||
)
|
||||
|
||||
_TAGS_SYSTEM_PROMPT = (
|
||||
"You are a keyword tagger for a knowledge base. "
|
||||
"Extract 5–8 short, lowercase keyword tags from the following conversation summary. "
|
||||
"Tags should represent the main topics, entities, and concepts discussed. "
|
||||
"Return ONLY a comma-separated list of tags with no other text or punctuation. "
|
||||
"Example output: api design, authentication, database schema, user roles, caching"
|
||||
)
|
||||
|
||||
_KB_CONTEXT_HEADER = (
|
||||
"The following are relevant past conversation summaries from your knowledge base. "
|
||||
"Use them as background context if they relate to the current question, "
|
||||
"but do not repeat their contents unless directly asked."
|
||||
)
|
||||
|
||||
|
||||
def _thread_key(update: Update) -> tuple[int, int] | None:
|
||||
"""Return the (chat_id, thread_id) key if the message is part of a thread, else None."""
|
||||
@@ -83,7 +97,7 @@ async def start_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
"/help – show available commands\n"
|
||||
"/clear – reset conversation history\n"
|
||||
"/flush – summarise and archive this thread's memory\n"
|
||||
"/recall – retrieve archived thread summaries\n"
|
||||
"/recall [query] – retrieve archived thread summaries\n"
|
||||
"/analyse – run a manual API analysis right now",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
@@ -103,7 +117,7 @@ async def help_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
"/clear – reset conversation history for this context\n"
|
||||
"/flush – summarise the current thread, store the summary, and compress memory\n"
|
||||
" _(only available inside a message thread)_\n"
|
||||
"/recall – show the stored summary for this thread, or list all summaries\n"
|
||||
"/recall [query] – show this thread's summary, list all summaries, or search by keyword\n"
|
||||
"/analyse – trigger an immediate API analysis and proposal",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
@@ -183,12 +197,20 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
system_prompt=_FLUSH_SYSTEM_PROMPT,
|
||||
)
|
||||
|
||||
# Extract keyword tags for knowledge-base indexing (second LLM call, lightweight)
|
||||
tags_raw = await llm.chat(
|
||||
f"Summary to tag:\n\n{summary_text}",
|
||||
system_prompt=_TAGS_SYSTEM_PROMPT,
|
||||
)
|
||||
tags = [t.strip().lower() for t in tags_raw.split(",") if t.strip()][:10]
|
||||
|
||||
message_count = sum(1 for m in history if m["role"] == "user")
|
||||
thread_summary = ThreadSummary(
|
||||
chat_id=chat_id,
|
||||
thread_id=thread_id,
|
||||
summary=summary_text,
|
||||
message_count=message_count,
|
||||
tags=tags,
|
||||
)
|
||||
store.save(thread_summary)
|
||||
|
||||
@@ -206,10 +228,14 @@ async def flush_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
|
||||
|
||||
|
||||
async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /recall – retrieve stored thread summaries.
|
||||
"""Handle /recall [query] – retrieve stored thread summaries.
|
||||
|
||||
Inside a thread: shows the stored summary for this thread (if any).
|
||||
Outside a thread: lists all stored summaries (newest first).
|
||||
With a query argument (e.g. ``/recall api design``): searches all stored
|
||||
summaries whose tags overlap with the query keywords and returns matches.
|
||||
|
||||
Without a query:
|
||||
- Inside a thread: shows the stored summary for this thread (if any).
|
||||
- Outside a thread: lists all stored summaries (newest first).
|
||||
"""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
store: ThreadMemoryStore = context.bot_data["thread_store"]
|
||||
@@ -218,6 +244,28 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
return
|
||||
|
||||
# If the user supplied a keyword query, search the knowledge base
|
||||
args: list[str] = context.args or [] # type: ignore[assignment]
|
||||
if args:
|
||||
query = " ".join(args).strip()
|
||||
results = store.search(query)
|
||||
if not results:
|
||||
await update.message.reply_text( # type: ignore[union-attr]
|
||||
f"No memories found matching *{query}*. "
|
||||
"Try a different keyword or use /flush to add more summaries.",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
)
|
||||
return
|
||||
lines = [f"\U0001f50d *Knowledge base search: {query}*\n"]
|
||||
for s in results:
|
||||
date = s.flushed_at[:10]
|
||||
first_line = s.summary.split("\n")[0][:80]
|
||||
lines.append(f"\u2022 Thread `{s.thread_id}` ({date}): {first_line}\u2026")
|
||||
if s.tags:
|
||||
lines.append(f" \U0001f3f7 {', '.join(s.tags)}")
|
||||
await _send_long(update, "\n".join(lines))
|
||||
return
|
||||
|
||||
key = _thread_key(update)
|
||||
if key is not None:
|
||||
chat_id, thread_id = key
|
||||
@@ -243,6 +291,8 @@ async def recall_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
date = s.flushed_at[:10]
|
||||
first_line = s.summary.split("\n")[0][:80]
|
||||
lines.append(f"\u2022 Thread `{s.thread_id}` ({date}): {first_line}\u2026")
|
||||
if s.tags:
|
||||
lines.append(f" \U0001f3f7 {', '.join(s.tags)}")
|
||||
await _send_long(update, "\n".join(lines))
|
||||
|
||||
|
||||
@@ -267,14 +317,37 @@ async def analyse_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
await _send_long(update, proposal.format_for_telegram())
|
||||
|
||||
|
||||
def _with_kb_context(
|
||||
history: list[dict[str, str]],
|
||||
relevant: list[ThreadSummary],
|
||||
) -> list[dict[str, str]]:
|
||||
"""Prepend relevant knowledge-base summaries as a transient system context message.
|
||||
|
||||
The returned list is a *new* list — the original *history* is not mutated.
|
||||
The injected message is never appended to the stored history, so it does not
|
||||
permanently consume the context window.
|
||||
"""
|
||||
if not relevant:
|
||||
return history
|
||||
snippets = [f"[Thread {s.thread_id}] {s.summary[:400]}" for s in relevant[:3]]
|
||||
kb_msg = _KB_CONTEXT_HEADER + "\n\n" + "\n\n---\n\n".join(snippets)
|
||||
return [{"role": "system", "content": kb_msg}, *history]
|
||||
|
||||
|
||||
async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle plain text messages – forward to LLM and reply.
|
||||
|
||||
Thread messages: history is stored unbounded under the (chat_id, thread_id) key.
|
||||
Non-thread messages: history is capped at _MAX_HISTORY turns per user.
|
||||
|
||||
Before each LLM call the knowledge base is searched for summaries whose tags
|
||||
overlap with keywords in the current message. Any matches are injected as
|
||||
transient context — they are NOT stored in the rolling history, so they do
|
||||
not permanently consume the context window.
|
||||
"""
|
||||
settings: Settings = context.bot_data["settings"]
|
||||
llm: LLMClient = context.bot_data["llm"]
|
||||
store: ThreadMemoryStore = context.bot_data["thread_store"]
|
||||
user = update.effective_user
|
||||
if user is None or not _is_allowed(user.id, settings):
|
||||
return
|
||||
@@ -287,13 +360,15 @@ async def message_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
if key is not None:
|
||||
# Thread message: unbounded history
|
||||
history = _thread_history[key]
|
||||
reply = await llm.chat(text, history=history)
|
||||
call_history = _with_kb_context(history, store.search(text))
|
||||
reply = await llm.chat(text, history=call_history)
|
||||
history.append({"role": "user", "content": text})
|
||||
history.append({"role": "assistant", "content": reply})
|
||||
else:
|
||||
# Non-thread message: capped history per user
|
||||
history = _history[user.id]
|
||||
reply = await llm.chat(text, history=history)
|
||||
call_history = _with_kb_context(history, store.search(text))
|
||||
reply = await llm.chat(text, history=call_history)
|
||||
history.append({"role": "user", "content": text})
|
||||
history.append({"role": "assistant", "content": reply})
|
||||
if len(history) > _MAX_HISTORY * 2:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Persistent storage for flushed thread summaries."""
|
||||
"""Persistent storage for flushed thread summaries (knowledge-base style)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,18 +13,36 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class ThreadSummary:
|
||||
"""A persisted summary of a flushed Telegram message thread."""
|
||||
"""A persisted summary of a flushed Telegram message thread.
|
||||
|
||||
``tags`` is a list of short lowercase keywords extracted by the LLM at flush
|
||||
time. They are used to index the knowledge base so summaries can be recalled
|
||||
contextually without being kept permanently in the conversation context.
|
||||
"""
|
||||
|
||||
chat_id: int
|
||||
thread_id: int
|
||||
summary: str
|
||||
message_count: int
|
||||
flushed_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
return f"{self.chat_id}:{self.thread_id}"
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> ThreadSummary:
|
||||
"""Deserialise from a raw dict, tolerating missing optional fields."""
|
||||
return cls(
|
||||
chat_id=int(data["chat_id"]), # type: ignore[arg-type]
|
||||
thread_id=int(data["thread_id"]), # type: ignore[arg-type]
|
||||
summary=str(data["summary"]),
|
||||
message_count=int(data["message_count"]), # type: ignore[arg-type]
|
||||
flushed_at=str(data.get("flushed_at", datetime.now(UTC).isoformat())),
|
||||
tags=list(data.get("tags", [])), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def format_for_telegram(self) -> str:
|
||||
"""Return a concise Telegram-formatted recall card."""
|
||||
flushed = self.flushed_at[:19].replace("T", " ")
|
||||
@@ -32,21 +50,24 @@ class ThreadSummary:
|
||||
f"\U0001f9e0 *Thread summary* (thread `{self.thread_id}`)\n"
|
||||
f"_Flushed: {flushed} UTC \u2014 {self.message_count} messages_"
|
||||
)
|
||||
return f"{header}\n\n{self.summary}"
|
||||
tag_line = f"\U0001f3f7 Tags: {', '.join(self.tags)}" if self.tags else ""
|
||||
parts = [header, tag_line, self.summary] if tag_line else [header, self.summary]
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
class ThreadMemoryStore:
|
||||
"""JSON-backed store for flushed thread summaries.
|
||||
"""JSON-backed knowledge-base store for flushed thread summaries.
|
||||
|
||||
The store is intentionally lightweight for the MVP. Each flush overwrites
|
||||
any previous summary for the same (chat_id, thread_id) pair.
|
||||
Summaries are indexed by keyword tags so they can be recalled contextually
|
||||
(via :meth:`search`) without being kept permanently in the LLM context.
|
||||
Each flush overwrites the previous summary for the same (chat_id, thread_id) pair.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path = "thread_memory.json") -> None:
|
||||
self._path = Path(path)
|
||||
self._data: dict[str, dict[str, int | str]] = self._load()
|
||||
self._data: dict[str, dict[str, object]] = self._load()
|
||||
|
||||
def _load(self) -> dict[str, dict[str, int | str]]:
|
||||
def _load(self) -> dict[str, dict[str, object]]:
|
||||
if self._path.exists():
|
||||
try:
|
||||
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
||||
@@ -76,9 +97,25 @@ class ThreadMemoryStore:
|
||||
raw = self._data.get(f"{chat_id}:{thread_id}")
|
||||
if raw is None:
|
||||
return None
|
||||
return ThreadSummary(**raw) # type: ignore[arg-type]
|
||||
return ThreadSummary.from_dict(raw)
|
||||
|
||||
def all(self) -> list[ThreadSummary]:
|
||||
"""Return all stored summaries, newest first."""
|
||||
entries = [ThreadSummary(**v) for v in self._data.values()] # type: ignore[arg-type]
|
||||
entries = [ThreadSummary.from_dict(v) for v in self._data.values()]
|
||||
return sorted(entries, key=lambda s: s.flushed_at, reverse=True)
|
||||
|
||||
def search(self, query: str) -> list[ThreadSummary]:
|
||||
"""Return summaries whose tags overlap with words in *query*, newest first.
|
||||
|
||||
The match is case-insensitive and word-based. Summaries without tags
|
||||
are not returned even if the query is broad.
|
||||
"""
|
||||
query_words = {w.lower() for w in query.split() if w}
|
||||
if not query_words:
|
||||
return []
|
||||
results = [
|
||||
ThreadSummary.from_dict(v)
|
||||
for v in self._data.values()
|
||||
if query_words & {t.lower() for t in v.get("tags", [])} # type: ignore[union-attr]
|
||||
]
|
||||
return sorted(results, key=lambda s: s.flushed_at, reverse=True)
|
||||
|
||||
Reference in New Issue
Block a user