diff --git a/README.md b/README.md
index 1a1d78e..059745c 100644
--- a/README.md
+++ b/README.md
@@ -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
` を使うと、添付したPDFから Gemini API で問題を自動生成できます。
+- このコマンドはユーザーID `705264675138568192` のみ実行できます。
+- PDFはコマンド送信時にメッセージへ添付してください。
diff --git a/cogs/memorization_cog.py b/cogs/memorization_cog.py
index b51a0eb..7d810b6 100644
--- a/cogs/memorization_cog.py
+++ b/cogs/memorization_cog.py
@@ -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
@@ -10,6 +13,8 @@
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()
@@ -17,6 +22,15 @@ def __init__(self,bot):
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="問題を追加します。")
@@ -26,10 +40,61 @@ async def add(self, interaction:discord.Interaction):
@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)
+ 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)
+ 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):
@@ -99,7 +164,7 @@ async def delete(self, interaction:discord.Interaction):
@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)
diff --git a/memorization_maker/README.md b/memorization_maker/README.md
index 756a678..92c8048 100644
--- a/memorization_maker/README.md
+++ b/memorization_maker/README.md
@@ -209,8 +209,17 @@ Discord Bot から呼び出され、ユーザーごとに問題集やジャン
- ジャンル(カテゴリ)の作成・削除・移動・共有
- 問題集・ジャンルの共有コードによる他ユーザーへの共有
- Excelファイルからの問題インポート
+- PDFファイルからの問題自動生成(Gemini API経由)
- 問題集のオーナー(管理者)管理
+## PDF自動生成
+
+- Discord のプレフィックスコマンド `m!add_pdf ` で実行する
+- 添付したPDFを Gemini API に渡して問題を生成する
+- 実行者はユーザーID `705264675138568192` のみ
+- Gemini API キーは `token.json` の `GEMINI_API_KEY` で読む
+- OCR は使わない
+
---
## 注意事項
diff --git a/memorization_maker/base_question_add.py b/memorization_maker/base_question_add.py
index 22276cd..f192c8b 100644
--- a/memorization_maker/base_question_add.py
+++ b/memorization_maker/base_question_add.py
@@ -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
@@ -29,6 +34,192 @@ def __init__(self):
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):
+ 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})
+ 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):
+ 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):
+ 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
+ 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)
@@ -52,17 +243,17 @@ async def add_misson_select(self,_sharecode,quetion:str,answer,select:list):
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):
# 正規表現で番号を探して置き換える
diff --git a/requirements.txt b/requirements.txt
index b783b8e..d67b8d1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,7 @@
discord.py>=2.3.2
typing_extensions>=4.0
-hss.py>=1.1.1
\ No newline at end of file
+hss.py>=1.1.1
+pypdf>=5.0.0
+google-genai>=1.0.0
+jishaku
+openpyxl
\ No newline at end of file