Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 12 additions & 125 deletions src/intelstream/discord/cogs/lore.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from discord import MessageType, app_commands
from discord.ext import commands, tasks

from intelstream.services.llm_client import LLMClient, LLMError, create_llm_client
from intelstream.services.llm_client import LLMClient, create_llm_client
from intelstream.services.message_ingestion import (
MessageChunker,
MessageIngestionService,
Expand All @@ -25,12 +25,10 @@
logger = structlog.get_logger(__name__)

LORE_SYSTEM_PROMPT = (
"You are a Discord server historian. Based on the message excerpts provided, "
"tell the story of what was asked about. Organize the information chronologically. "
"Include specific quotes when they are interesting or funny. Mention usernames. "
"If the excerpts don't contain enough information to tell a complete story, "
"say what you found and note that the history may be incomplete. "
"Be conversational and engaging -- this is server lore, not a formal report."
"Based on the message excerpts provided, answer the user's question. "
"Organize the information chronologically. Include specific quotes when relevant. "
"Mention usernames. If the excerpts don't contain enough information, "
"say what you found and note that the history may be incomplete."
)

BUFFER_FLUSH_MINUTES = 5
Expand Down Expand Up @@ -130,117 +128,19 @@ async def cog_unload(self) -> None:
channel="Limit search to a specific channel",
timeframe="Time range, e.g. 'last 6 months', '2024'",
)
@app_commands.checks.cooldown(rate=1, per=30.0)
async def lore(
self,
interaction: discord.Interaction,
query: str,
channel: discord.TextChannel | None = None,
timeframe: str | None = None,
query: str, # noqa: ARG002
channel: discord.TextChannel | None = None, # noqa: ARG002
timeframe: str | None = None, # noqa: ARG002
) -> None:
await interaction.response.defer()

guild_id = str(interaction.guild_id) if interaction.guild_id else None
if not guild_id:
await interaction.followup.send(
"This command can only be used in a server.", ephemeral=True
)
return

if self._llm_client is None:
await interaction.followup.send(
"Lore queries are unavailable. No LLM API key is configured.",
ephemeral=True,
)
return

logger.info(
"Lore query",
user_id=interaction.user.id,
query=query,
channel=channel.name if channel else None,
timeframe=timeframe,
await interaction.response.send_message(
"The /lore command is temporarily disabled while the message index is being built. "
"Check back soon!",
ephemeral=True,
)

query_embedding = await self._embedding_service.embed_text(query)
topk = self.bot.settings.lore_search_results * 2
results = await self._vector_store.search_message_chunks(query_embedding, topk=topk)

if not results:
still_building = (
self._ingestion_service is not None and self._ingestion_service.is_running
)
msg = "No lore found yet."
if still_building:
msg += " The message index is still being built -- try again soon."
await interaction.followup.send(msg, ephemeral=True)
return

chunk_ids = [r.chunk_id for r in results]
metas = await self.bot.repository.get_message_chunk_metas_by_ids(chunk_ids)
metas_by_id = {m.id: m for m in metas}

filtered = []
for result in results:
meta = metas_by_id.get(result.chunk_id)
if meta is None:
continue
if meta.guild_id != guild_id:
continue
if channel and meta.channel_id != str(channel.id):
continue
if timeframe:
start_dt, end_dt = _parse_timeframe(timeframe)
if start_dt and meta.end_timestamp < start_dt:
continue
if end_dt and meta.start_timestamp > end_dt:
continue
filtered.append(meta)

max_results = self.bot.settings.lore_search_results
filtered = filtered[:max_results]

if not filtered:
await interaction.followup.send(
"No relevant lore found for that query.", ephemeral=True
)
return

filtered.sort(key=lambda m: m.start_timestamp)

context_parts = []
for meta in filtered:
ch_name = meta.channel_name or "unknown"
ts = meta.start_timestamp.strftime("%Y-%m-%d")
context_parts.append(f"--- #{ch_name} ({ts}) ---\n{meta.text}")
context_text = "\n\n".join(context_parts)

prompt = (
f"Based on the following message excerpts from this server's history, "
f'tell the story of: "{query}"\n\n'
f"--- Message History ---\n{context_text}"
)

try:
response_text = await self._llm_client.complete(
system=LORE_SYSTEM_PROMPT,
user_message=prompt,
max_tokens=2048,
)
except LLMError as e:
logger.error("Failed to generate lore response", error=str(e))
await interaction.followup.send(
"Failed to generate lore response. Please try again later.", ephemeral=True
)
return

if len(response_text) <= MAX_DISCORD_MESSAGE_LENGTH:
await interaction.followup.send(response_text)
else:
parts = _split_message(response_text)
for part in parts:
await interaction.followup.send(part)

@commands.Cog.listener("on_message")
async def on_message(self, message: discord.Message) -> None:
if not message.guild:
Expand Down Expand Up @@ -339,16 +239,3 @@ async def auto_start_ingestion(self) -> None:
for guild in self.bot.guilds:
await self.start_ingestion_for_guild(guild)
break

@lore.error
async def lore_error(
self, interaction: discord.Interaction, error: app_commands.AppCommandError
) -> None:
if isinstance(error, app_commands.CommandOnCooldown):
minutes, seconds = divmod(int(error.retry_after), 60)
time_str = f"{minutes}m {seconds}s" if minutes > 0 else f"{seconds}s"
await interaction.response.send_message(
f"Lore queries are on cooldown. Try again in {time_str}.", ephemeral=True
)
else:
raise error
6 changes: 6 additions & 0 deletions src/intelstream/services/message_ingestion.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ async def ingest_channel(
total_fetched = progress.total_fetched or 0
chunks_stored = 0
messages_since_checkpoint = 0
latest_message_date: str | None = None

try:
async for msg in channel.history(
Expand All @@ -300,13 +301,15 @@ async def ingest_channel(
progress=progress_label,
channel=channel_name,
fetched=total_fetched,
latest_date=latest_message_date,
)
return

raw = discord_message_to_raw(msg)
buffer.append(raw)
total_fetched += 1
messages_since_checkpoint += 1
latest_message_date = raw.created_at.strftime("%Y-%m-%d")

if messages_since_checkpoint >= CHECKPOINT_INTERVAL:
chunks = self._chunker.chunk_messages(
Expand Down Expand Up @@ -346,6 +349,7 @@ async def ingest_channel(
channel=channel_name,
fetched=total_fetched,
chunks=chunks_stored,
latest_date=latest_message_date,
)

if total_fetched % YIELD_INTERVAL == 0:
Expand All @@ -370,6 +374,7 @@ async def ingest_channel(
channel=channel_name,
fetched=total_fetched,
chunks=chunks_stored,
latest_date=latest_message_date,
)

except Exception:
Expand All @@ -379,6 +384,7 @@ async def ingest_channel(
channel=channel_name,
fetched=total_fetched,
chunks=chunks_stored,
latest_date=latest_message_date,
)
if buffer:
last_id = str(buffer[-1].id)
Expand Down
73 changes: 4 additions & 69 deletions tests/test_discord/test_lore.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import discord
import pytest

from intelstream.database.vector_store import ChunkSearchResult
from intelstream.discord.cogs.lore import Lore, _parse_timeframe, _split_message


Expand Down Expand Up @@ -131,75 +130,11 @@ def test_no_newline_splits_at_max(self):


class TestLoreQuery:
async def test_query_no_guild(self, lore_cog, mock_interaction):
mock_interaction.guild_id = None
await lore_cog.lore.callback(lore_cog, mock_interaction, "test")
mock_interaction.followup.send.assert_called_once()
assert "server" in mock_interaction.followup.send.call_args[0][0].lower()

async def test_query_no_results(self, lore_cog, mock_interaction, mock_vector_store):
mock_vector_store.search_message_chunks.return_value = []
async def test_command_temporarily_disabled(self, lore_cog, mock_interaction):
await lore_cog.lore.callback(lore_cog, mock_interaction, "test query")
mock_interaction.followup.send.assert_called_once()
msg = mock_interaction.followup.send.call_args[0][0].lower()
assert "no lore" in msg

async def test_query_no_results_while_building(
self, lore_cog, mock_interaction, mock_vector_store
):
mock_vector_store.search_message_chunks.return_value = []
lore_cog._ingestion_service.is_running = True
await lore_cog.lore.callback(lore_cog, mock_interaction, "test query")
mock_interaction.followup.send.assert_called_once()
msg = mock_interaction.followup.send.call_args[0][0].lower()
assert "still being built" in msg

async def test_query_with_results(
self, lore_cog, mock_interaction, mock_vector_store, mock_bot
):
mock_vector_store.search_message_chunks.return_value = [
ChunkSearchResult(chunk_id="chunk-1", score=0.9),
]

meta = MagicMock()
meta.id = "chunk-1"
meta.guild_id = str(mock_interaction.guild_id)
meta.channel_id = "999"
meta.channel_name = "general"
meta.start_timestamp = datetime(2024, 6, 1, tzinfo=UTC)
meta.end_timestamp = datetime(2024, 6, 1, 1, 0, tzinfo=UTC)
meta.text = "Some conversation text here"
mock_bot.repository.get_message_chunk_metas_by_ids.return_value = [meta]

lore_cog._llm_client.complete = AsyncMock(return_value="Here is the lore about that topic.")

await lore_cog.lore.callback(lore_cog, mock_interaction, "test query")
mock_interaction.followup.send.assert_called()
sent_text = mock_interaction.followup.send.call_args[0][0]
assert "lore" in sent_text.lower()

async def test_query_filters_other_guild(
self, lore_cog, mock_interaction, mock_vector_store, mock_bot
):
mock_vector_store.search_message_chunks.return_value = [
ChunkSearchResult(chunk_id="chunk-1", score=0.9),
]

meta = MagicMock()
meta.id = "chunk-1"
meta.guild_id = "999999"
meta.channel_id = "999"
mock_bot.repository.get_message_chunk_metas_by_ids.return_value = [meta]

await lore_cog.lore.callback(lore_cog, mock_interaction, "test query")
mock_interaction.followup.send.assert_called()
assert "no relevant" in mock_interaction.followup.send.call_args[0][0].lower()

async def test_query_no_llm_client(self, lore_cog, mock_interaction):
lore_cog._llm_client = None
await lore_cog.lore.callback(lore_cog, mock_interaction, "test query")
mock_interaction.followup.send.assert_called_once()
assert "unavailable" in mock_interaction.followup.send.call_args[0][0].lower()
mock_interaction.response.send_message.assert_called_once()
msg = mock_interaction.response.send_message.call_args[0][0].lower()
assert "temporarily disabled" in msg


class TestLoreCogLoadWithoutApiKey:
Expand Down
Loading