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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,14 @@ HSS APIのpythonラッパー`hss.py`の見本用bot。
"memorization": {}
}
```
4. `py main.py`などのコマンドで実行すればok
4. `token.json` に Gemini API キーを追加する。
```json
{"TOKEN_2": "...(Bot TOKEN)", "GEMINI_API_KEY": "...(Gemini API Key)"}
```
5. `py main.py`などのコマンドで実行すればok

## 暗記メモリ作成

- `m!add_pdf <title>` を使うと、添付したPDFから Gemini API で問題を自動生成できます。
- このコマンドはユーザーID `705264675138568192` のみ実行できます。
- PDFはコマンド送信時にメッセージへ添付してください。
67 changes: 66 additions & 1 deletion cogs/memorization_cog.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from discord.ext import commands
from discord import app_commands
import discord
import json
import importlib.util
import random
import memorization_discord.memorization_maker_add as maker_add
import memorization_discord.select_title as select_title
from memorization_maker.genre import Genre
Expand All @@ -10,13 +13,24 @@
from memorization_maker.share import Share

class MemorizationCog(commands.Cog):
OWNER_ID = 705264675138568192

def __init__(self,bot):
self.bot:discord.Client = bot
self.adds = Add()
self.genres = Genre()
self.shares = Share()
self.get = Get()

def _load_gemini_api_key(self):
try:
with open("token.json", "r", encoding="utf-8") as file:
token_data = json.load(file)
except (FileNotFoundError, json.JSONDecodeError):
return None

return token_data.get("GEMINI_API_KEY") or token_data.get("GEMINI_KEY") or token_data.get("GENAI_API_KEY")

memorization = app_commands.Group(name="memorization", description="暗記メーカ")

@memorization.command(name="add", description="問題を追加します。")
Expand All @@ -26,10 +40,61 @@
@memorization.command(name="add_excel", description="エクセルファイルから問題を追加します。")
async def add_excel(self, interaction:discord.Interaction,excel:discord.Attachment,title:str):
_sharecode = await self.shares.make_sharecode()
if _sharecode is None:
return await interaction.response.send_message("共有コードの生成に失敗しました。", ephemeral=True)
await self.adds.init_add(str(interaction.user.id),title,_sharecode)
await self.adds.add_misson_in_Excel(_sharecode,excel)
await self.genres.add_genre(str(interaction.user.id),"default",_sharecode)
await interaction.response.send_message("追加しました。", ephemeral=True)

@commands.command(name="add_pdf", description="PDFファイルから問題を追加します。タイトルの後に任意でAIへの指令を追加できます。")
async def add_pdf(self, ctx, title: str, *ai_instruction: str):
if ctx.author.id != self.OWNER_ID:
return await ctx.send("403 Forbidden")

pdf_attachment = next((attachment for attachment in ctx.message.attachments if attachment.filename.lower().endswith(".pdf")), None)
if pdf_attachment is None:
return await ctx.send("PDFファイルを添付してください。")

api_key = self._load_gemini_api_key()
if not api_key:
return await ctx.send("token.json に GEMINI_API_KEY を追加してください。")

# 必要なランタイム依存をチェック
if importlib.util.find_spec("google.genai") is None or importlib.util.find_spec("pypdf") is None:
return await ctx.send("依存ライブラリが不足しています。まずリポジトリのルートで次を実行してください:\n```\npip install -r requirements.txt\n```")
desired_count = None
ai_instruction_text = None
if ai_instruction:
first = ai_instruction[0]
try:
desired_count = int(first)
rest = ai_instruction[1:]
except Exception:
rest = ai_instruction
ai_instruction_text = " ".join(rest).strip() if rest else None

# 問題数: 指定がなければ 40〜60 の範囲でランダムに決定、指定があれば最大100で上限
if desired_count is None:
max_q = random.randint(40, 60)

Check warning on line 79 in cogs/memorization_cog.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cogs/memorization_cog.py#L79

Depending on the context, generating weak random numbers may expose cryptographic functions, which rely on these numbers, to be exploitable.

Check warning on line 79 in cogs/memorization_cog.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cogs/memorization_cog.py#L79

Standard pseudo-random generators are not suitable for security/cryptographic purposes.
else:
max_q = max(1, min(desired_count, 100))
async with ctx.typing():
questions = await self.adds.generate_questions_from_pdf(pdf_attachment, api_key, max_questions=max_q, force_mode=1, ai_instruction=ai_instruction_text)

Check notice on line 83 in cogs/memorization_cog.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

cogs/memorization_cog.py#L83

line too long (163 > 159 characters) (E501)
if not questions:
return await ctx.send("PDFから問題を生成できませんでした。")

_sharecode = await self.shares.make_sharecode()
if _sharecode is None:
return await ctx.send("共有コードの生成に失敗しました。")
if not await self.adds.init_add(str(ctx.author.id), title, _sharecode):
return await ctx.send("タイトルの作成に失敗しました。")

if not await self.adds.add_generated_questions(_sharecode, questions):
return await ctx.send("問題の保存に失敗しました。")

await self.genres.add_genre(str(ctx.author.id), "default", _sharecode)
await ctx.send(f"追加しました。{len(questions)}問を生成しました。")

@memorization.command(name="edit", description="問題を編集します。")
async def edit(self, interaction:discord.Interaction):
Expand Down Expand Up @@ -99,7 +164,7 @@

@commands.command(name="add_vo", description="問題を追加します。")
async def add_vocabulary(self, ctx, title: str, start_number: int, end_number: int, mode:int = 0):
if ctx.author.id == 705264675138568192:
if ctx.author.id == self.OWNER_ID:
self.vocabulary = Vocabulary()
if end_number - start_number > 100:return await ctx.send("100問までです。")
await self.vocabulary.make_vocabulary(str(ctx.author.id), title, start_number, end_number, mode)
Expand Down
9 changes: 9 additions & 0 deletions memorization_maker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,17 @@ Discord Bot から呼び出され、ユーザーごとに問題集やジャン
- ジャンル(カテゴリ)の作成・削除・移動・共有
- 問題集・ジャンルの共有コードによる他ユーザーへの共有
- Excelファイルからの問題インポート
- PDFファイルからの問題自動生成(Gemini API経由)
- 問題集のオーナー(管理者)管理

## PDF自動生成

- Discord のプレフィックスコマンド `m!add_pdf <title>` で実行する
- 添付したPDFを Gemini API に渡して問題を生成する
- 実行者はユーザーID `705264675138568192` のみ
- Gemini API キーは `token.json` の `GEMINI_API_KEY` で読む
- OCR は使わない

---

## 注意事項
Expand Down
199 changes: 195 additions & 4 deletions memorization_maker/base_question_add.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import asyncio
import json
from importlib import import_module
import memorization_maker.Read_and_Write as Read_and_Write
import os
import re
import tempfile
from typing import TypedDict
from typing_extensions import NotRequired
from typing import Union
Expand Down Expand Up @@ -29,6 +34,192 @@
self.rw = Read_and_Write.Read_and_Write()
self.base_data:dict = {"memorization":{}, "genre":{}}

def _strip_code_fence(self, text: str) -> str:
text = text.strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text)
text = re.sub(r"\s*```$", "", text)
return text.strip()

def _extract_json_payload(self, text: str):
text = self._strip_code_fence(text)
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start == -1 or end == -1 or end <= start:
raise
return json.loads(text[start:end + 1])

def _normalize_generated_questions(self, payload):

Check warning on line 55 in memorization_maker/base_question_add.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

memorization_maker/base_question_add.py#L55

Add._normalize_generated_questions is too complex (23) (MC0001)
if isinstance(payload, dict):
questions = payload.get("questions", [])
elif isinstance(payload, list):
questions = payload
else:
return []

normalized = []
for question in questions:
if not isinstance(question, dict):
continue

mode = question.get("mode")
if mode is None:
if isinstance(question.get("select"), list):
mode = 1
elif isinstance(question.get("answer"), list):
mode = 2
else:
mode = 0

try:
mode = int(mode)
except (TypeError, ValueError):
continue

question_text = str(question.get("question", "")).strip()
if not question_text:
continue

if mode == 0:
answer = str(question.get("answer", "")).strip()
if not answer:
continue
normalized.append({"question": question_text, "answer": answer, "mode": 0})
elif mode == 1:
select = question.get("select", [])
if not isinstance(select, list) or len(select) < 2:
continue
select = [str(choice).strip() for choice in select if str(choice).strip()]
if len(select) < 2:
continue

answer = question.get("answer")
if isinstance(answer, str) and answer.isdigit():
answer = int(answer)
try:
answer = int(answer)
except (TypeError, ValueError):
continue
if answer < 0 or answer >= len(select):
continue

normalized.append({"question": question_text, "answer": answer, "mode": 1, "select": select})
Comment on lines +92 to +109
elif mode == 2:
answer = question.get("answer", [])
if not isinstance(answer, list):
continue
answers = [str(item).strip() for item in answer if str(item).strip()]
if not answers:
continue
normalized.append({"question": question_text, "answer": answers, "mode": 2})

return normalized

async def _extract_pdf_text(self, pdf_path: str) -> str:
pdf_module = import_module("pypdf")
reader = pdf_module.PdfReader(pdf_path)
pages = []
for index, page in enumerate(reader.pages[:20], start=1):
page_text = page.extract_text() or ""
page_text = page_text.strip()
if page_text:
pages.append(f"--- page {index} ---\n{page_text[:4000]}")
return "\n\n".join(pages)

def _generate_questions_with_gemini(self, pdf_path: str, extracted_text: str, api_key: str, max_questions: int, force_mode: int | None = None, ai_instruction: str | None = None):

Check notice on line 132 in memorization_maker/base_question_add.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

memorization_maker/base_question_add.py#L132

line too long (182 > 159 characters) (E501)
from google import genai

client = genai.Client(api_key=api_key)
uploaded_pdf = client.files.upload(file=pdf_path)
prompt = (
"あなたはPDF教材から暗記問題を作るアシスタントです。"
"添付されたPDFを読み、問題をJSONのみで返してください。"
"出力は必ず次の形式にしてください。\n"
'{"questions":[{"mode":0,"question":"...","answer":"..."},'
'{"mode":1,"question":"...","select":["...","...","...","..."],"answer":0},'
'{"mode":2,"question":"...","answer":["..."]}]}'
f"問題数は最大 {max_questions} 問にしてください。\n"
"mode 0 は記述式、mode 1 は4択、mode 2 は穴埋めです。\n"
"選択式は必ず4個の選択肢を入れ、answer は 0 から 3 の整数にしてください。\n"
"穴埋めは答えを配列で返してください。\n"
"余計な説明文、Markdown、コードフェンスは返さないでください。\n"
)
# 強制モードが指定されている場合は生成ルールを絞る
if force_mode is not None:
if force_mode == 1:
prompt += "\n注意: 生成する問題はすべて選択式(mode 1:4択)のみとしてください。answer は 0 から 3 の整数で返してください。\n"
elif force_mode == 2:
prompt += "\n注意: 生成する問題はすべて穴埋め(mode 2)のみとしてください。\n"
elif force_mode == 0:
prompt += "\n注意: 生成する問題はすべて記述式(mode 0)のみとしてください。\n"
# ユーザ指定のAI指令を追記
if ai_instruction:
prompt += "\n追加の指示: " + ai_instruction + "\n"
if extracted_text:
prompt += "\nPDFから抽出したテキストの補助情報:\n" + extracted_text

response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[prompt, uploaded_pdf],
)
raw_text = getattr(response, "text", "") or ""
if not raw_text:
return []

payload = self._extract_json_payload(raw_text)
return self._normalize_generated_questions(payload)

async def generate_questions_from_pdf(self, pdffile, api_key: str, max_questions: int = 50, force_mode: int | None = None, ai_instruction: str | None = None):

Check notice on line 175 in memorization_maker/base_question_add.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

memorization_maker/base_question_add.py#L175

line too long (162 > 159 characters) (E501)
pdf_bytes = await pdffile.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as temp_file:
temp_file.write(pdf_bytes)
temp_path = temp_file.name

try:
extracted_text = await self._extract_pdf_text(temp_path)
# max_questions の範囲を制限(MAX=100)
try:
max_q = int(max_questions)
except Exception:
max_q = 50
if max_q > 100:
max_q = 100
if max_q < 1:
max_q = 1
questions = await asyncio.to_thread(
self._generate_questions_with_gemini,
temp_path,
extracted_text,
api_key,
max_q,
force_mode,
ai_instruction,
)
return questions
Comment on lines +181 to +201
finally:
try:
os.remove(temp_path)
except OSError:
pass

async def add_generated_questions(self, _sharecode, questions):
self.base_data:dict = await self.rw.load_base()
sharecode = str(_sharecode)
if sharecode not in self.base_data["memorization"]:
return False

for question in questions:
record = {"question": question["question"], "answer": question["answer"], "mode": question["mode"]}
if question["mode"] == 1:
record["select"] = question["select"]
self.base_data["memorization"][sharecode]["questions"].append(record)

await self.rw.write_base(self.base_data)
return True

async def init_add(self,user_id:str,title:str,_sharecode):
num_id = str(user_id)
sharecode = str(_sharecode)
Expand All @@ -52,17 +243,17 @@
self.base_data["memorization"][sharecode]["questions"].append({"question":quetion,"answer":answer,"mode":1,"select":select})
await self.rw.write_base(self.base_data)

async def replace_parentheses(self,text):
async def replace_parentheses(self, source_text: str):
# ()内のテキストを抽出
answers:list = re.findall(r'\((.*?)\)', text)
answers:list = re.findall(r'\((.*?)\)', source_text)

# ()内のテキストを①、②、③...に置き換え
if len(answers) > 5:
return False, False
for i, answer in enumerate(answers):
number = chr(9312 + i) # 9312はUnicodeで①の値
text:str = text.replace(f'({answer})', f'{number}')
return text, answers
source_text = source_text.replace(f'({answer})', f'{number}')
return source_text, answers

def replace_numbers_with_answers(self,text, answers):
# 正規表現で番号を探して置き換える
Expand Down
6 changes: 5 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
discord.py>=2.3.2
typing_extensions>=4.0
hss.py>=1.1.1
hss.py>=1.1.1
pypdf>=5.0.0

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2025-55197: pypdf: PyPDF RAM Exhaustion Vulnerability) (update to 6.0.0)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2025-62707: pypdf: pypdf affected by possible infinite loop when reading DCT inline images without EOF marker) (update to 6.1.3)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2025-62708: pypdf: pypdf manipulated LZWDecode streams can exhaust RAM) (update to 6.1.3)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2025-66019: pypdf: pypdf manipulated LZWDecode streams can exhaust RAM) (update to 6.4.0)

Check notice on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-22690: pypdf: pypdf: Denial of Service via crafted PDF with missing /Root object) (update to 6.6.0)

Check notice on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-22691: pypdf: pypdf: Denial of Service via malformed PDF startxref entries) (update to 6.6.0)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-24688: pypdf: pypdf Infinite Loop when processing outlines/bookmarks) (update to 6.6.2)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-27024: pypdf: pypdf: Denial of Service via crafted PDF with TreeObject outlines) (update to 6.7.1)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-27025: pypdf: pypdf: Denial of Service via crafted PDF with large font values) (update to 6.7.1)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-27026: pypdf: pypdf: Denial of Service via malformed PDF /FlateDecode stream) (update to 6.7.1)

Check notice on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-27628: pypdf: possible infinite loop when loading circular /Prev entries in cross-reference streams) (update to 6.7.2)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-27888: pypdf: pypdf: Denial of Service via crafted PDF) (update to 6.7.3)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-28351: pypdf: pypdf: Denial of Service via crafted PDF with RunLengthDecode filter) (update to 6.7.4)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-28804: pypdf: pypdf: Denial of Service via crafted PDF with ASCIIHexDecode filter) (update to 6.7.5)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-31826: pypdf: pypdf: Denial of Service due to excessive memory consumption via crafted PDF) (update to 6.8.0)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-33123: pypdf: pypdf: Denial of Service due to excessive resource consumption from crafted PDF) (update to 6.9.1)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-33699: pypdf: pypdf: Denial of Service via crafted PDF in non-strict mode) (update to 6.9.2)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-40260: pypdf is a free and open-source pure-python PDF library. In versions p ...) (update to 6.10.0)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-41168: pypdf: pypdf: Denial of Service via crafted PDF with oversized streams) (update to 6.10.1)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-41312: pypdf: pypdf: Denial of Service due to excessive memory consumption via specially crafted PDF) (update to 6.10.2)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-41313: pypdf: pypdf: Denial of Service via crafted PDF with large trailer /Size value) (update to 6.10.2)

Check warning on line 4 in requirements.txt

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

requirements.txt#L4

Insecure dependency pypi/pypdf@5.0.0 (CVE-2026-41314: pypdf: pypdf: Denial of Service via crafted PDF with large image sizes) (update to 6.10.2)
google-genai>=1.0.0
jishaku
openpyxl