Skip to content

Commit fa734b9

Browse files
committedApr 13, 2025
✨ feat: Add LLM integration for LeetCode problems
Adds LLM translation and inspire features to the bot. - Integrates Google Gemini for problem translation and insight generation. - Introduces buttons for problem description, LLM translation, and inspiration. - Implements caching for LLM results to improve response times and reduce API usage. - Enhances .env with Google Gemini API key.
1 parent 1c26622 commit fa734b9

File tree

10 files changed

+1518
-22
lines changed

10 files changed

+1518
-22
lines changed
 

‎.env.example

+4-1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@ DISCORD_TOKEN=your_discord_bot_token_here
55
POST_TIME=00:00
66

77
# Default timezone (Example: UTC, Asia/Taipei, America/New_York)
8-
TIMEZONE=UTC
8+
TIMEZONE=UTC
9+
10+
# Google Gemini API Key
11+
GOOGLE_GEMINI_API_KEY=your_google_gemini_api_key_here

‎bot.py

+195-17
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,25 @@
1212
from utils.logger import setup_logging, get_logger
1313

1414
from leetcode import LeetCodeClient, html_to_text
15+
from llms import GeminiLLM
1516
from utils import SettingsDatabaseManager
17+
from utils.database import LLMTranslateDatabaseManager, LLMInspireDatabaseManager
1618
from discord.ui import View, Button
1719

1820
# Set up logging
1921
setup_logging()
2022
logger = get_logger("bot")
2123

24+
# Load environment variables
2225
load_dotenv(dotenv_path='.env', verbose=True, override=True)
2326
DISCORD_TOKEN = os.getenv('DISCORD_TOKEN')
2427
POST_TIME = os.getenv('POST_TIME', '00:00') # Default to 00:00
2528
TIMEZONE = os.getenv('TIMEZONE', 'UTC') # Default to UTC
2629

2730
# Initialize the database manager
2831
db = SettingsDatabaseManager()
32+
llm_translate_db = LLMTranslateDatabaseManager()
33+
llm_inspire_db = LLMInspireDatabaseManager()
2934

3035
# Initialize LeetCode client
3136
lcus = LeetCodeClient()
@@ -36,22 +41,178 @@
3641
intents.message_content = True # Enable message content permission
3742
bot = commands.Bot(command_prefix="!", intents=intents)
3843

44+
# LLM
45+
try:
46+
llm = GeminiLLM(model="gemini-2.0-flash")
47+
llm_pro = GeminiLLM(model="gemini-2.5-pro-preview-03-25")
48+
except Exception as e:
49+
logger.error(f"Error while initializing LLM: {e}")
50+
llm = None
51+
llm_pro = None
52+
3953
# Schedule tasks are stored here to be cancelled later
4054
schedule_tasks = {}
4155

4256
# Define a fixed custom ID prefix
43-
LEETCODE_BUTTON_PREFIX = "leetcode_problem_"
44-
57+
# LEETCODE_DISCRIPTION_BUTTON_PREFIX = "leetcode_problem_"
58+
LEETCODE_DISCRIPTION_BUTTON_PREFIX = "leetcode_problem_"
59+
LEETCODE_TRANSLATE_BUTTON_PREFIX = "leetcode_translate_"
60+
LEETCODE_INSPIRE_BUTTON_PREFIX = "leetcode_inspire_"
4561
# Global interaction event handler
4662
@bot.event
4763
async def on_interaction(interaction):
4864
# Ignore non-button interactions
4965
if interaction.type != discord.InteractionType.component:
5066
return
51-
52-
# Check if it's our LeetCode button
67+
5368
custom_id = interaction.data.get("custom_id", "")
54-
if custom_id.startswith(LEETCODE_BUTTON_PREFIX):
69+
70+
# Button for LLM translation
71+
if custom_id.startswith(LEETCODE_TRANSLATE_BUTTON_PREFIX):
72+
logger.debug(f"接收到LeetCode LLM翻譯按鈕交互: custom_id={custom_id}")
73+
try:
74+
# 先 defer,避免 interaction 過期
75+
await interaction.response.defer(ephemeral=True)
76+
parts = custom_id.split("_")
77+
# 格式: leetcode_translate_{problem_id}_{domain}
78+
problem_id = parts[2]
79+
domain = parts[3] if len(parts) > 3 else "com"
80+
81+
logger.debug(f"嘗試獲取題目並進行LLM翻譯: problem_id={problem_id}, domain={domain}")
82+
83+
client = lcus if domain == "com" else lccn
84+
85+
if problem_id and problem_id.isdigit():
86+
# 先查詢 DB cache
87+
translation = llm_translate_db.get_translation(int(problem_id), domain)
88+
if translation:
89+
logger.debug(f"從DB取得LLM翻譯: problem_id={problem_id}")
90+
await interaction.followup.send(translation, ephemeral=True)
91+
return
92+
93+
problem_info = await client.get_problem(problem_id=problem_id)
94+
if problem_info and problem_info.get("content"):
95+
problem_content = html_to_text(problem_info["content"])
96+
# LLM 翻譯
97+
try:
98+
translation = llm.translate(problem_content, "zh-TW")
99+
# 長度限制
100+
if len(translation) > 1900:
101+
translation = translation[:1900] + "...\n(翻譯內容已截斷)"
102+
# 寫入 DB
103+
llm_translate_db.save_translation(int(problem_id), domain, translation)
104+
await interaction.followup.send(translation, ephemeral=True)
105+
logger.debug(f"成功發送LLM翻譯並寫入DB: problem_id={problem_id}")
106+
except Exception as llm_e:
107+
logger.error(f"LLM 翻譯失敗: {llm_e}", exc_info=True)
108+
await interaction.followup.send(f"LLM 翻譯失敗:{str(llm_e)}", ephemeral=True)
109+
else:
110+
logger.warning(f"題目沒有內容: problem_id={problem_id}")
111+
await interaction.followup.send("無法獲取題目描述,請前往 LeetCode 網站查看。", ephemeral=True)
112+
else:
113+
logger.warning(f"無效的題目ID: {problem_id}")
114+
await interaction.followup.send("無效的題目ID,無法顯示翻譯。", ephemeral=True)
115+
except discord.errors.InteractionResponded:
116+
await interaction.followup.send("已經回應過此交互,請重新點擊按鈕。", ephemeral=True)
117+
except Exception as e:
118+
logger.error(f"處理LLM翻譯按鈕交互時發生錯誤: {e}", exc_info=True)
119+
try:
120+
await interaction.followup.send(f"LLM 翻譯時發生錯誤:{str(e)}", ephemeral=True)
121+
except:
122+
pass
123+
return
124+
125+
# Button for LLM inspire
126+
def format_inspire_field(val):
127+
if isinstance(val, list):
128+
return '\n'.join(f"- {x}" for x in val)
129+
return str(val)
130+
131+
INSPIRE_FIELDS = [
132+
("thinking", "🧠 思路"),
133+
("traps", "⚠️ 陷阱"),
134+
("algorithms", "🛠️ 推薦演算法"),
135+
("inspiration", "✨ 其他靈感"),
136+
]
137+
138+
if custom_id.startswith(LEETCODE_INSPIRE_BUTTON_PREFIX):
139+
logger.debug(f"接收到LeetCode 靈感啟發按鈕交互: custom_id={custom_id}")
140+
try:
141+
await interaction.response.defer(ephemeral=True)
142+
parts = custom_id.split("_")
143+
# 格式: leetcode_inspire_{problem_id}_{domain}
144+
problem_id = parts[2]
145+
domain = parts[3] if len(parts) > 3 else "com"
146+
147+
logger.debug(f"嘗試獲取題目並進行LLM靈感啟發: problem_id={problem_id}, domain={domain}")
148+
149+
if not problem_id or not problem_id.isdigit():
150+
logger.warning(f"無效的題目ID: {problem_id}")
151+
await interaction.followup.send("無效的題目ID,無法顯示靈感啟發。", ephemeral=True)
152+
return
153+
154+
inspire_result = llm_inspire_db.get_inspire(int(problem_id), domain)
155+
if inspire_result:
156+
logger.debug(f"Get inspire result from DB: problem_id={problem_id}")
157+
else:
158+
client = lcus if domain == "com" else lccn
159+
problem_info = await client.get_problem(problem_id=problem_id)
160+
if problem_info and problem_info.get("content"):
161+
problem_content = html_to_text(problem_info["content"])
162+
tags = problem_info.get("tags", [])
163+
difficulty = problem_info.get("difficulty", "")
164+
else:
165+
logger.warning(f"題目沒有內容: problem_id={problem_id}")
166+
await interaction.followup.send("無法獲取題目資訊。", ephemeral=True)
167+
return
168+
169+
# Get inspire result from LLM
170+
try:
171+
inspire_result = llm_pro.inspire(problem_content, tags, difficulty)
172+
if not isinstance(inspire_result, dict) or not all(k in inspire_result for k in ["thinking", "traps", "algorithms", "inspiration"]):
173+
# 回傳原始 LLM 回覆
174+
raw = inspire_result.get("raw", inspire_result)
175+
if len(str(raw)) > 1900:
176+
raw = str(raw)[:1900] + "...\n(內容已截斷)"
177+
await interaction.followup.send(str(raw), ephemeral=True)
178+
logger.debug(f"發送原始 LLM 靈感回覆: problem_id={problem_id}")
179+
return
180+
# --- DB cache: save inspire result ---
181+
formatted_fields = [format_inspire_field(inspire_result[k]) for k, _ in INSPIRE_FIELDS]
182+
llm_inspire_db.save_inspire(
183+
int(problem_id), domain,
184+
*formatted_fields
185+
)
186+
except Exception as llm_e:
187+
logger.error(f"LLM 靈感啟發失敗: {llm_e}", exc_info=True)
188+
await interaction.followup.send(f"LLM 靈感啟發失敗:{str(llm_e)}", ephemeral=True)
189+
return
190+
191+
embed = discord.Embed(
192+
title="💡 靈感啟發",
193+
color=0x8e44ad,
194+
)
195+
total_len = 0
196+
for key, field_name in INSPIRE_FIELDS:
197+
val = format_inspire_field(inspire_result.get(key, ""))
198+
embed.add_field(name=field_name, value=val, inline=False)
199+
total_len += len(val)
200+
if total_len > 1800:
201+
embed.set_footer(text="內容已截斷,請嘗試更精簡提示。")
202+
await interaction.followup.send(embed=embed, ephemeral=True)
203+
204+
except discord.errors.InteractionResponded:
205+
await interaction.followup.send("已經回應過此交互,請重新點擊按鈕。", ephemeral=True)
206+
except Exception as e:
207+
logger.error(f"處理LLM靈感啟發按鈕交互時發生錯誤: {e}", exc_info=True)
208+
try:
209+
await interaction.followup.send(f"LLM 靈感啟發時發生錯誤:{str(e)}", ephemeral=True)
210+
except:
211+
pass
212+
return
213+
214+
# Button for displaying LeetCode problem description
215+
if custom_id.startswith(LEETCODE_DISCRIPTION_BUTTON_PREFIX):
55216
logger.debug(f"接收到LeetCode按鈕交互: custom_id={custom_id}")
56217

57218
# Parse problem ID and domain
@@ -297,21 +458,38 @@ async def send_daily_challenge(channel_id=None, role_id=None, interaction=None,
297458
embed.add_field(name="🔍 Similar Questions", value="\n".join(similar_questions), inline=False)
298459

299460
embed.set_footer(text=f"LeetCode Daily Challenge | {info['date']}", icon_url="https://leetcode.com/static/images/LeetCode_logo.png")
300-
301-
# 創建一個自訂ID,包含問題ID和域名
302-
custom_id = f"{LEETCODE_BUTTON_PREFIX}{info['id']}_{domain}"
303-
304-
# 創建按鈕組件
305-
button = discord.ui.Button(
461+
462+
# Create a view containing the button
463+
view = discord.ui.View(timeout=None)
464+
465+
# Create a button for displaying the problem description
466+
description_button = discord.ui.Button(
306467
style=discord.ButtonStyle.primary,
307-
label="顯示題目描述(僅自己可見)",
468+
label="題目描述",
308469
emoji="📖",
309-
custom_id=custom_id
470+
custom_id=f"{LEETCODE_DISCRIPTION_BUTTON_PREFIX}{info['id']}_{domain}"
310471
)
311-
312-
# 創建包含按鈕的視圖
313-
view = discord.ui.View(timeout=None)
314-
view.add_item(button)
472+
view.add_item(description_button)
473+
474+
# Add LLM translation button
475+
translate_custom_id = f"{LEETCODE_TRANSLATE_BUTTON_PREFIX}{info['id']}_{domain}"
476+
translate_button = discord.ui.Button(
477+
style=discord.ButtonStyle.success,
478+
label="LLM 翻譯",
479+
emoji="🌐",
480+
custom_id=translate_custom_id
481+
)
482+
view.add_item(translate_button)
483+
484+
# Add LLM inspire button
485+
inspire_custom_id = f"{LEETCODE_INSPIRE_BUTTON_PREFIX}{info['id']}_{domain}"
486+
inspire_button = discord.ui.Button(
487+
style=discord.ButtonStyle.danger,
488+
label="靈感啟發",
489+
emoji="💡",
490+
custom_id=inspire_custom_id
491+
)
492+
view.add_item(inspire_button)
315493

316494
# Determine how to send the message
317495
if interaction:

‎llms/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .base import LLMBase
2+
from .gemini import GeminiLLM

‎llms/base.py

+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
from abc import ABC, abstractmethod
2+
from langchain_core.output_parsers import SimpleJsonOutputParser
3+
4+
from llms.templates import *
5+
6+
class LLMBase(ABC):
7+
"""
8+
LLMBase is the abstract base class for all LLM implementations.
9+
All subclasses must implement the generate method.
10+
11+
Methods:
12+
generate(prompt: str) -> str
13+
Generate a response from the LLM based on the input prompt.
14+
"""
15+
def __init__(self):
16+
self.llm = None
17+
18+
@abstractmethod
19+
def generate(self, prompt: str) -> str:
20+
"""
21+
Generate a response from the LLM based on the input prompt.
22+
23+
Args:
24+
prompt (str): The input prompt.
25+
26+
Returns:
27+
str: The generated response.
28+
"""
29+
pass
30+
31+
def translate(self, content: str, target_language: str, from_lang: str = "auto") -> str:
32+
"""
33+
Translate the input content to the target language using the LLM.
34+
35+
Args:
36+
content (str): The text to be translated.
37+
from_lang (str): The source language for translation., default is "auto"
38+
target_language (str): The target language for translation, default is "zh-TW"
39+
40+
Returns:
41+
str: The translated text, or the original LLM response if parsing fails.
42+
"""
43+
44+
prompt = TRANSLATION_JSON_PROMPT_TEMPLATE.format(
45+
to=target_language,
46+
from_lang=from_lang,
47+
text=content,
48+
)
49+
50+
response = self.generate(prompt)
51+
parser = SimpleJsonOutputParser()
52+
try:
53+
parsed = parser.parse(response)
54+
return parsed["translation"]
55+
except Exception:
56+
return response
57+
58+
def inspire(self, content: str, tags: list, difficulty: str) -> dict:
59+
"""
60+
根據題目描述、tags、難度,產生解題靈感(僅繁體中文,禁止程式碼),回傳 JSON dict。
61+
Args:
62+
content (str): 題目描述
63+
tags (list): 題目標籤
64+
difficulty (str): 題目難度
65+
Returns:
66+
dict: { "thinking": ..., "traps": ..., "algorithms": ..., "inspiration": ... }
67+
若解析失敗則回傳 {"raw": response}
68+
"""
69+
prompt = INSPIRE_JSON_PROMPT_TEMPLATE.format(
70+
text=content,
71+
tags=", ".join(tags) if tags else "",
72+
difficulty=difficulty
73+
)
74+
response = self.generate(prompt)
75+
parser = SimpleJsonOutputParser()
76+
try:
77+
parsed = parser.parse(response)
78+
return parsed
79+
except Exception:
80+
return {"raw": response}
81+

0 commit comments

Comments
 (0)
Please sign in to comment.