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 <title>` で実行する +- 添付した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