Skip to content

Commit e13f1e0

Browse files
Merge pull request #6 from kpj2006/main
feat: add gap logging functionality and improve bot thread management
2 parents ba49d03 + 6ef33d7 commit e13f1e0

4 files changed

Lines changed: 230 additions & 35 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,4 +326,6 @@ TSWLatexianTemp*
326326
#*Notes.bib
327327

328328
venv/
329-
.env
329+
.env
330+
__pycache__/
331+
.commandcode/

bot.py

Lines changed: 188 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import os
2+
import json
23
import logging
34
import discord
45
import httpx
56
import asyncio
7+
from datetime import datetime, timezone
8+
from pathlib import Path
69
from dotenv import load_dotenv
710

811
# Configure logging
@@ -18,6 +21,8 @@
1821
OLLAMA_MODEL = os.getenv('OLLAMA_MODEL', 'llama3.2')
1922
SKILL_FILE_PATH = os.getenv('SKILL_FILE_PATH', '.clinerules')
2023
OLLAMA_URL = "http://localhost:11434/api/generate"
24+
GAP_LOG_PATH = Path("gap_log.json")
25+
MAX_RETRIES = 3
2126

2227
# Initialize bot with intents
2328
intents = discord.Intents.default()
@@ -27,6 +32,41 @@
2732
# Lock to prevent Ollama requests from clashing
2833
ollama_lock = asyncio.Lock()
2934

35+
THREAD_HISTORY_LIMIT = 10 # messages to pull from thread as conversation context
36+
37+
38+
def _load_gap_log():
39+
if GAP_LOG_PATH.exists():
40+
try:
41+
with open(GAP_LOG_PATH, "r", encoding="utf-8") as f:
42+
return json.load(f)
43+
except json.JSONDecodeError:
44+
logger.warning("gap_log.json corrupted, starting fresh")
45+
return []
46+
47+
48+
def _save_gap_log(entries):
49+
GAP_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
50+
with open(GAP_LOG_PATH, "w", encoding="utf-8") as f:
51+
json.dump(entries, f, indent=2, default=str)
52+
53+
gap_log_lock = asyncio.Lock()
54+
55+
async def _log_gap(query, reason, thread_id=None):
56+
async with gap_log_lock:
57+
entry = {
58+
"timestamp": datetime.now(timezone.utc).isoformat(),
59+
"query": query,
60+
"reason": reason,
61+
}
62+
if thread_id:
63+
entry["thread_id"] = thread_id
64+
entries = _load_gap_log()
65+
entries.append(entry)
66+
_save_gap_log(entries)
67+
logger.info(f"Gap logged: {reason} — query: {query[:80]}")
68+
69+
3070
def load_skill_context() -> str:
3171
"""Load context from the local skill file."""
3272
try:
@@ -37,8 +77,9 @@ def load_skill_context() -> str:
3777
logger.error(f"Error loading skill file {SKILL_FILE_PATH}: {e}")
3878
return ""
3979

40-
async def generate_ollama_response(prompt: str, context: str) -> str:
41-
"""Send prompt to local Ollama instance and return the response."""
80+
81+
async def generate_ollama_response(prompt: str, context: str) -> tuple[str, bool]:
82+
"""Send prompt to local Ollama instance. Returns (response_text, used_llm_fallback)."""
4283
if context:
4384
system_prompt = f"You are a helpful contributor assistant for AOSSIE.\n\nContext guidelines:\n{context}"
4485
else:
@@ -51,37 +92,149 @@ async def generate_ollama_response(prompt: str, context: str) -> str:
5192
"stream": False
5293
}
5394

95+
for attempt in range(1, MAX_RETRIES + 1):
96+
try:
97+
async with httpx.AsyncClient(timeout=120.0) as http_client:
98+
response = await http_client.post(OLLAMA_URL, json=payload)
99+
response.raise_for_status()
100+
data = response.json()
101+
text = data.get("response", "")
102+
if text:
103+
return text, False # False = Ollama succeeded, no fallback gap
104+
logger.warning(f"Empty Ollama response (attempt {attempt}/{MAX_RETRIES})")
105+
except httpx.TimeoutException:
106+
logger.error(f"Ollama timed out (attempt {attempt}/{MAX_RETRIES})")
107+
except httpx.RequestError as e:
108+
logger.error(f"Ollama unreachable (attempt {attempt}/{MAX_RETRIES}): {e}")
109+
except Exception as e:
110+
logger.error(f"Ollama error (attempt {attempt}/{MAX_RETRIES}): {e}")
111+
if attempt < MAX_RETRIES:
112+
await asyncio.sleep(2)
113+
114+
return "I'm sorry, the local AI model is currently unavailable. Please try again later or ask a maintainer.", True
115+
116+
117+
async def _build_conversation_context(thread: discord.Thread, current_author: discord.User, current_query: str) -> str:
118+
"""Pull recent thread history and format it as conversation context for Ollama."""
119+
history_parts = []
120+
try:
121+
async for msg in thread.history(limit=THREAD_HISTORY_LIMIT, oldest_first=True):
122+
if msg.author.bot:
123+
history_parts.append(f"Bot: {msg.content[:300]}")
124+
else:
125+
history_parts.append(f"{msg.author.display_name}: {msg.content[:300]}")
126+
except Exception as e:
127+
logger.error(f"Error fetching thread history for {thread.id}: {e}")
128+
129+
if not history_parts:
130+
return ""
131+
132+
return (
133+
"Previous conversation in this thread:\n" +
134+
"\n".join(history_parts) +
135+
f"\n\nCurrent question from {current_author.display_name}: {current_query}"
136+
)
137+
138+
139+
async def _get_or_create_thread(message: discord.Message, channel: discord.TextChannel) -> discord.Thread | None:
140+
"""If message is already in a thread, return that thread. Otherwise create a new one.
141+
One thread per conversation — never reuses threads by user ID. Returns None on failure."""
142+
if isinstance(message.channel, discord.Thread):
143+
thread = message.channel
144+
if not thread.archived and not thread.locked:
145+
return thread
146+
logger.warning(f"Thread {thread.id} is archived/locked — creating a new one")
147+
return None # cannot create thread from message already in a thread
148+
54149
try:
55-
async with httpx.AsyncClient(timeout=120.0) as http_client:
56-
response = await http_client.post(OLLAMA_URL, json=payload)
57-
response.raise_for_status()
58-
data = response.json()
59-
return data.get("response", "Error: No response text found in Ollama reply.")
60-
except httpx.TimeoutException:
61-
logger.error("Ollama request timed out.")
62-
return "I'm sorry, the local AI model timed out while thinking. Please try again later."
63-
except httpx.RequestError as e:
64-
logger.error(f"Ollama request error: {e}")
65-
return f"I'm sorry, I couldn't reach the local AI engine. Ensure Ollama is running at localhost:11434."
150+
author = message.author
151+
thread = await message.create_thread(
152+
name=f"Q&A: {author.display_name}{message.content[:50]}",
153+
auto_archive_duration=1440, # 24 hours
154+
)
155+
logger.info(f"Created thread {thread.id} for {author.name} — query: {message.content[:80]}")
156+
return thread
157+
except discord.Forbidden:
158+
logger.error(f"Cannot create thread — missing permissions in channel {channel.id}")
159+
except discord.HTTPException as e:
160+
logger.error(f"Discord API error creating thread: {e}")
66161
except Exception as e:
67-
logger.error(f"Unexpected error during Ollama generation: {e}")
68-
return "An unexpected error occurred while generating the response."
162+
logger.error(f"Unexpected error creating thread for {message.author.id}: {e}")
163+
return None
164+
69165

70166
async def process_message(message: discord.Message):
71-
"""Process a single message and generate a reply safely."""
72-
if message.author.bot or message.channel.id != DISCORD_CHANNEL_ID_INT:
167+
"""Process a single message: new messages in the main channel spawn a thread,
168+
messages in existing threads continue the conversation there."""
169+
if message.author.bot:
73170
return
74171

75-
# Use lock to ensure only one message is processed by Ollama at a time
172+
is_in_thread = isinstance(message.channel, discord.Thread)
173+
is_in_configured_channel = message.channel.id == DISCORD_CHANNEL_ID_INT
174+
175+
if not is_in_thread and not is_in_configured_channel:
176+
return
177+
178+
author = message.author
179+
180+
if is_in_thread:
181+
thread = message.channel
182+
if thread.archived or thread.locked:
183+
logger.warning(f"Thread {thread.id} is archived/locked — cannot respond")
184+
return
185+
else:
186+
channel = message.channel
187+
thread = await _get_or_create_thread(message, channel)
188+
if not thread:
189+
_log_gap(message.content, "thread_creation_failed")
190+
try:
191+
await message.reply(
192+
"I couldn't create a thread to answer your question. Please ask a maintainer for help."
193+
)
194+
except Exception:
195+
pass
196+
return
197+
76198
async with ollama_lock:
77-
async with message.channel.typing():
199+
try:
200+
await asyncio.sleep(1) # let Discord register the new thread
201+
async with thread.typing():
202+
pass
203+
except Exception as e:
204+
logger.warning(f"Could not trigger typing indicator in thread {thread.id}: {e}")
205+
206+
try:
78207
skill_context = load_skill_context()
79-
response_text = await generate_ollama_response(message.content, skill_context)
80-
81-
if len(response_text) > 1900:
82-
response_text = response_text[:1896] + "..."
208+
conversation_context = await _build_conversation_context(thread, author, message.content)
209+
210+
if conversation_context:
211+
full_prompt = conversation_context
212+
else:
213+
full_prompt = message.content
214+
215+
response_text, used_fallback = await generate_ollama_response(full_prompt, skill_context)
216+
217+
if used_fallback or not skill_context:
218+
_log_gap(
219+
message.content,
220+
"ollama_unavailable" if used_fallback else "no_skill_context",
221+
thread_id=thread.id,
222+
)
223+
except Exception as e:
224+
logger.error(f"Unexpected error processing message from {author.name}: {e}")
225+
response_text = "An unexpected error occurred. Please try again or ask a maintainer."
226+
_log_gap(message.content, f"processing_error: {e}", thread_id=thread.id)
227+
228+
if len(response_text) > 1900:
229+
response_text = response_text[:1896] + "..."
230+
231+
try:
232+
await thread.send(response_text)
233+
except discord.Forbidden:
234+
logger.error(f"Cannot send message to thread {thread.id}")
235+
except discord.HTTPException as e:
236+
logger.error(f"Error sending to thread {thread.id}: {e}")
83237

84-
await message.reply(response_text)
85238

86239
async def wait_for_ollama():
87240
"""Wait until Ollama is up and responding."""
@@ -98,50 +251,51 @@ async def wait_for_ollama():
98251
logger.info("Ollama not reachable yet. Retrying in 10 seconds...")
99252
await asyncio.sleep(10)
100253

254+
101255
@client.event
102256
async def on_ready():
103257
logger.info(f"Logged in as {client.user.name} ({client.user.id})")
104-
258+
105259
# Wait for Ollama to be ready before processing the backlog
106260
await wait_for_ollama()
107-
261+
108262
logger.info("Checking for missed messages...")
109-
263+
110264
try:
111265
channel = await client.fetch_channel(DISCORD_CHANNEL_ID_INT)
112-
266+
113267
# Find the last message sent by the bot
114268
last_bot_msg = None
115269
async for msg in channel.history(limit=50):
116270
if msg.author.id == client.user.id:
117271
last_bot_msg = msg
118272
break
119-
273+
120274
messages_to_process = []
121275
if last_bot_msg:
122-
# Fetch messages after the bot's last message
123276
async for msg in channel.history(after=last_bot_msg, oldest_first=True):
124277
if not msg.author.bot:
125278
messages_to_process.append(msg)
126279
else:
127-
# If no bot message found, just process the last 5 user messages
128280
async for msg in channel.history(limit=5, oldest_first=True):
129281
if not msg.author.bot:
130282
messages_to_process.append(msg)
131-
283+
132284
logger.info(f"Found {len(messages_to_process)} missed messages. Processing...")
133285
for msg in messages_to_process:
134286
await process_message(msg)
135-
287+
136288
except Exception as e:
137289
logger.error(f"Error fetching missed messages: {e}")
138290

139291
logger.info("AOSSIE Contributor Assistant MVP is fully ready.")
140292

293+
141294
@client.event
142295
async def on_message(message: discord.Message):
143296
await process_message(message)
144297

298+
145299
if __name__ == "__main__":
146300
if not DISCORD_TOKEN:
147301
logger.critical("DISCORD_TOKEN is missing from environment. Exiting.")
@@ -160,4 +314,4 @@ async def on_message(message: discord.Message):
160314
exit(1)
161315

162316
logger.info("Starting bot...")
163-
client.run(DISCORD_TOKEN)
317+
client.run(DISCORD_TOKEN)

gap_log.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
[
2+
{
3+
"timestamp": "2026-06-11T06:28:50.131419+00:00",
4+
"query": "hi",
5+
"reason": "ollama_unavailable",
6+
"thread_id": 1514516717588451369
7+
},
8+
{
9+
"timestamp": "2026-06-11T06:29:33.996652+00:00",
10+
"query": "hi",
11+
"reason": "ollama_unavailable",
12+
"thread_id": 1514516902330896445
13+
},
14+
{
15+
"timestamp": "2026-06-11T06:43:02.871048+00:00",
16+
"query": "hi",
17+
"reason": "ollama_unavailable",
18+
"thread_id": 1514520294717526096
19+
},
20+
{
21+
"timestamp": "2026-06-11T06:43:09.796853+00:00",
22+
"query": "hi",
23+
"reason": "ollama_unavailable",
24+
"thread_id": 1514520323834511490
25+
},
26+
{
27+
"timestamp": "2026-06-11T06:43:47.538207+00:00",
28+
"query": "hi",
29+
"reason": "ollama_unavailable",
30+
"thread_id": 1514520482131607553
31+
}
32+
]

start_bot_hidden.vbs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Set WshShell = CreateObject("WScript.Shell")
2+
Dim botDir
3+
Set WshShell = CreateObject("WScript.Shell")
4+
Set fso = CreateObject("Scripting.FileSystemObject")
5+
Dim botDir
6+
botDir = fso.GetParentFolderName(WScript.ScriptFullName)
7+
WshShell.Run "cmd /c cd /d """ & botDir & """ && venv\Scripts\python.exe bot.py", 0, False

0 commit comments

Comments
 (0)