make_textbook5.py ダウンロード/コピー
make_textbook5.py
make_textbook5.py
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3"""
4AI処理専用:講義テキスト(文字起こし)とスライドMarkdownから教科書用/スライド用Markdownを生成します。
5
6AIを活用して、講義の文字起こしテキストとMarkdown形式のスライドを基に、
7教育用の教科書Markdownと、より洗練されたスライド用Markdownを自動生成します。
8OpenAI (GPTシリーズ) とGoogle (Geminiシリーズ) の両APIに対応しており、
9それぞれ最適なメッセージ構築形式を自動で選択します。
10AIへの入力となるメッセージはデバッグ用に`.log`ファイルとして保存されます。
11
12- OpenAI: system + user の会話形式でメッセージを構築します。
13- Google: 1つのuserメッセージにすべての情報をまとめる形式でメッセージを構築します。
14- AIに渡すmessagesを .log ファイルに保存します。
15
16:doc:`make_textbook5_usage`
17"""
18
19import os
20import sys
21import argparse
22import re
23import json
24from pathlib import Path
25
26try:
27 import google.generativeai as genai
28 from openai import OpenAI
29except ImportError:
30 print("必要なライブラリがインストールされていません。", file=sys.stderr)
31 print("pip install google-generativeai openai python-dotenv を実行してください。", file=sys.stderr)
32 input("\nPress ENTER to terminate>>\n")
33 sys.exit(1)
34
35from tkai_lib import read_ai_config
36
37
38PROMPT_TEMPLATE_JA = None
39PROMPT_TEMPLATE_EN = None
40
41language_map = {
42 'jp': '日本語',
43 'ja': '日本語',
44 'en': '米国英語',
45 'cn': '標準中国語',
46 'zh': '標準中国語',
47 'kr': '韓国語',
48 'ko': '韓国語',
49 }
50
51pause = 0
52
53
54def terminate():
55 """
56 プログラムを終了します。
57
58 詳細説明:
59 グローバル変数`pause`が真(1)の場合、ユーザーにEnterキーの入力を促してからプログラムを終了します。
60 これにより、コンソールウィンドウがすぐに閉じられるのを防ぎ、出力の確認を可能にします。
61
62 :returns: なし
63 """
64 if pause:
65 input("\nPress ENTER to terminate>>\n")
66 exit()
67
68def search_file(infile=None):
69 """
70 指定されたファイルをカレントディレクトリまたはスクリプトディレクトリから検索します。
71
72 詳細説明:
73 `infile`がNoneの場合、スクリプト名に基づいたデフォルトのINIファイル名(例: `make_textbook5.ini`)を生成します。
74 このデフォルトファイルは、まずカレントディレクトリで検索され、次に見つからなければスクリプトが配置されているディレクトリで検索されます。
75 `infile`が指定された場合、そのパスが存在するかを確認し、存在しない場合はスクリプトディレクトリ内での存在も確認します。
76
77 :param infile: str, optional
78 検索するファイルのパス。Noneの場合、デフォルトのINIファイルを検索します。
79 :returns: str or None
80 見つかったファイルの絶対パス。見つからない場合はNone。
81 """
82 script_path = os.path.abspath(sys.argv[0])
83 script_dir = os.path.dirname(script_path)
84 script_name = os.path.splitext(os.path.basename(script_path))[0]
85 default_ini = f"{script_name}.ini"
86
87 if infile is None:
88 for path in [os.getcwd(), script_dir]:
89 candidate = os.path.join(path, default_ini)
90 if os.path.isfile(candidate):
91 return candidate
92 return None
93
94 if not os.path.isfile(infile):
95 candidate = os.path.join(script_dir, infile)
96 if os.path.isfile(candidate):
97 return candidate
98 return None
99
100 return infile
101
102def read_ini(inifile=None):
103 """
104 INIファイルから設定を読み込み、キーと値の辞書として返します。
105
106 詳細説明:
107 指定されたINIファイルを検索し、読み込みます。
108 コメント行(`#`または`;`で始まる行)および空行はスキップされます。
109 `key=value`形式の行を解析し、辞書に格納します。
110 三重引用符で囲まれた複数行の値をサポートします。
111 読み込み後、辞書内の値に対して`$VAR`形式の変数展開を行います。
112
113 :param inifile: str, optional
114 読み込むINIファイルのパス。Noneの場合、`search_file`関数でデフォルトのINIファイルを検索します。
115 :returns: dict
116 INIファイルから読み込まれた設定を格納した辞書。
117 :raises FileNotFoundError: 指定されたINIファイルが見つからない場合。
118 """
119 path = search_file(inifile)
120 if path is None:
121 raise FileNotFoundError("INIファイルが見つかりませんでした")
122
123 result = {}
124 variables = {}
125 current_key = None
126 multiline_val = []
127 multiline_delim = None
128
129 with open(path, 'r', encoding='utf-8') as f:
130 for line in f:
131 line = line.rstrip()
132
133 if not line or line.startswith('#') or line.startswith(';'):
134 continue
135
136 # 複数行値の終了判定(stripで判定)
137 if multiline_delim:
138 if line.strip() == multiline_delim:
139 val = '\n'.join(multiline_val)
140 result[current_key] = val
141 variables[current_key] = val
142 current_key = None
143 multiline_val = []
144 multiline_delim = None
145 else:
146 multiline_val.append(line)
147 continue
148
149 # key=val の解析
150 if '=' in line:
151 key, val = map(str.strip, line.split('=', 1))
152 val = val.strip()
153
154 # 複数行値の開始判定(空文字でも対応)
155 if (val == '"""' or val == "'''" or
156 (val.startswith('"""') and not val.endswith('"""')) or \
157 (val.startswith("'''") and not val.endswith("'''")) ):
158 multiline_delim = val[:3]
159 content = val[3:]
160 multiline_val = [content] if content else []
161 current_key = key
162 continue
163
164 # 単一行の複数行値
165 if (val.startswith('"""') and val.endswith('"""')) or \
166 (val.startswith("'''") and val.endswith("'''")):
167 val = val[3:-3]
168
169 result[key] = val
170 variables[key] = val
171
172 # 変数展開(あとから一括処理)
173 for key, val in result.items():
174 def expand_var(match):
175 var_name = match.group(1)
176 return variables.get(var_name, match.group(0))
177 result[key] = re.sub(r"\$(\w+)\b", expand_var, val)
178
179 return result
180
181
182def parse_args():
183 """
184 コマンドライン引数を解析し、AI設定ファイルを読み込みます。
185
186 詳細説明:
187 `argparse`モジュールを使用して、入力ファイル、出力ファイル、使用するAI API、
188 モデル名、出力言語、専門分野、役割などのコマンドライン引数を解析します。
189 AIの設定は`ai.env`から`read_ai_config`で読み込まれ、APIキーは環境変数から取得されます。
190 プロンプトテンプレートはINIファイルから読み込まれ、グローバル変数`PROMPT_TEMPLATE_JA`と`PROMPT_TEMPLATE_EN`に設定されます。
191 必要な入力ファイルが指定されていない場合はエラーを出力し、プログラムを終了します。
192
193 :returns: tuple (argparse.ArgumentParser, argparse.Namespace)
194 `argparse.ArgumentParser`オブジェクトと、解析された引数を格納する`argparse.Namespace`オブジェクトのタプル。
195 """
196 global PROMPT_TEMPLATE_JA, PROMPT_TEMPLATE_EN
197
198 read_ai_config('ai.env')
199
200 p = argparse.ArgumentParser(
201 description="講義の文字起こしとスライドをAIで教科書/スライドMarkdownに変換(Pandoc不要)。",
202 formatter_class=argparse.RawTextHelpFormatter
203 )
204 p.add_argument('--inifile', default = None, help='プロンプトなどを保存したkey=valファイル')
205 p.add_argument('-i', '--infile', default = None, help='文字起こしテキストファイル (例: lecture.txt)')
206 p.add_argument('-im', '--in_slide', default = None, help='入力 講義スライドMarkdown (任意, 例: slide.md)')
207 p.add_argument('-t', '--textbook', help='出力 教科書Markdown (デフォルト: [infile]_textbook.md)')
208 p.add_argument('-s', '--slide', help='出力 スライドMarkdown (デフォルト: [infile]_slide.md)')
209
210 ai = p.add_argument_group('AI設定')
211 ai.add_argument('--api', '-a', choices=['gemini', 'openai5', 'openai', 'google'], default='gemini', help='使用API')
212 ai.add_argument('--model', help='明示モデル名の指定(apiごとに適用先を切替)')
213 ai.add_argument('--openai_model', default=os.getenv("OPENAI_MODEL", "gpt-4o"))
214 ai.add_argument('--openai_model5', default=os.getenv("OPENAI_MODEL5", "gpt-5.2"))
215# ai.add_argument('--google_model', default=os.getenv("GOOGLE_MODEL", "gemini-3-preview"))
216 ai.add_argument('--google_model', default=os.getenv("GOOGLE_MODEL", "gemini-2.5-flash"))
217
218 ai.add_argument('--lang', default='ja', choices=['ja', 'en', 'zh', 'ko'], help='出力言語 (デフォルト ja)')
219 ai.add_argument('--field', default='半導体工学', help='専門分野')
220 ai.add_argument('--role', default='大学教授', help='役割')
221 ai.add_argument('--pause', type=int, default=0, help="終了時にENTERキー入力を要求するか (デフォルト: 0)")
222 args = p.parse_args()
223
224 if args.infile is None and args.in_slide is None:
225 print("❌ --infile(-i)か--in_slide(-im)のどちらかを与えないといけません")
226 terminate()
227
228 args.openai_key = os.getenv("OPENAI_API_KEY")
229 args.gemini_key = os.getenv("GOOGLE_API_KEY")
230
231 if args.model:
232 if args.api == 'openai5': args.openai_model5 = args.model
233 elif args.api == 'openai': args.openai_model = args.model
234 elif args.api in ('gemini', 'google'): args.google_model = args.model
235
236 args.inifile = search_file(args.inifile)
237 print("Prompot inifile: ", args.inifile)
238 inf = read_ini(args.inifile)
239 PROMPT_TEMPLATE_JA = inf["PROMPT_TEMPLATE_JA"]
240 PROMPT_TEMPLATE_EN = inf["PROMPT_TEMPLATE_EN"]
241
242 return p, args
243
244def build_messages(api_choice: str, system_instructions: str, lecture_text: str, slide_markdown: str, final_instruction: str):
245 """
246 指定されたAIプラットフォーム向けにメッセージリストを構築します。
247
248 詳細説明:
249 AI APIの種類(OpenAIまたはGoogle Gemini)に応じて、適切な形式でメッセージリストを構築します。
250 OpenAI (gpt-4oなど) および Open AI Responses (仮のopenai5) の場合は、
251 `system`ロールと複数の`user`ロールで構成される会話形式を採用します。
252 Google Gemini (gemini-2.5-flashなど) の場合は、すべての情報を1つの`user`メッセージにまとめます。
253
254 :param api_choice: str
255 使用するAI APIの選択肢 ('openai', 'openai5', 'gemini', 'google')。
256 :param system_instructions: str
257 AIへのシステム指示(プロンプト)。
258 :param lecture_text: str or None
259 講義の文字起こしテキスト。Noneの場合は「文字起こしテキストはありません。」と伝えます。
260 :param slide_markdown: str
261 スライドのMarkdownテキスト。空文字の場合はスライドは提供されません。
262 :param final_instruction: str
263 最終的な出力形式に関する指示。
264 :returns: list of dict
265 AIに渡すためのメッセージリスト。
266 """
267 messages = []
268
269 if api_choice in ('openai', 'openai5'):
270 # OpenAI形式
271 messages.append({"role": "system", "content": system_instructions})
272
273 if lecture_text:
274 messages.append({"role": "user", "content": f"# 文字起こしテキスト\n\n{lecture_text}"})
275 else:
276 messages.append({"role": "user", "content": "文字起こしテキストはありません。"})
277
278 if slide_markdown:
279 messages.append({"role": "user", "content": f"# 講義スライド\n\n{slide_markdown}"})
280
281 messages.append({"role": "user", "content": final_instruction})
282
283 elif api_choice in ('gemini', 'google'):
284 # Google/Gemini形式: まとめる
285 parts = []
286 parts.append(system_instructions)
287
288 if lecture_text:
289 parts.append(f"# 文字起こしテキスト\n\n{lecture_text}")
290 else:
291 parts.append("文字起こしテキストはありません。")
292
293 if slide_markdown:
294 parts.append(f"# 講義スライド\n\n{slide_markdown}")
295
296 parts.append(final_instruction)
297
298 messages = [{"role": "user", "content": "\n\n".join(parts)}]
299
300 return messages
301
302
303def save_messages_log(messages: list, infile: str):
304 """
305 AIとの対話メッセージリストをJSON形式でログファイルに保存します。
306
307 詳細説明:
308 `infile`のファイル名(拡張子なし)を基にログファイル名(例: `lecture.log`)を生成します。
309 メッセージリストは、デバッグや検証のために読みやすい整形されたJSON形式で保存されます。
310 ファイル保存中にエラーが発生した場合は、エラーメッセージを標準エラー出力に表示します。
311
312 :param messages: list of dict
313 保存するメッセージリスト。
314 :param infile: str or None
315 元の入力ファイルのパス(ログファイル名生成用)。
316 Noneの場合、"output.log"が生成されます。
317 :returns: なし
318 """
319 if infile:
320 base = Path(infile).stem
321 else:
322 base = "output"
323 log_file = f"{base}.log"
324
325 try:
326 with open(log_file, "w", encoding="utf-8") as f:
327 json.dump(messages, f, ensure_ascii=False, indent=2)
328 print(f"📝 messagesログを '{log_file}' に保存しました")
329 except Exception as e:
330 print(f"⚠️ ログ保存に失敗しました: {e}", file=sys.stderr)
331
332def call_ai_api(messages: list, api_choice: str, *, openai_key=None, openai_model=None,
333 openai_model5=None, gemini_key=None, gemini_model=None) -> str:
334 """
335 指定されたAIサービス(OpenAIまたはGoogle Gemini)のAPIを呼び出します。
336
337 詳細説明:
338 `api_choice`に基づいて、適切なAPIクライアントとモデルを選択し、AIにメッセージを送信します。
339 OpenAI API (`openai`) は`client.chat.completions.create`を使用します。
340 OpenAI Responses API (`openai5`) は、仮の`client.responses.create`を使用することを想定しています。
341 Google Gemini API (`gemini`, `google`) は`genai.GenerativeModel`と`model.generate_content`を使用します。
342 APIキーが設定されていない場合や、未対応のAPIが指定された場合はエラーを発生させます。
343
344 :param messages: list of dict
345 AIに送信するメッセージリスト。
346 :param api_choice: str
347 使用するAI APIの選択肢 ('openai', 'openai5', 'gemini', 'google')。
348 :param openai_key: str, optional
349 OpenAI APIキー。
350 :param openai_model: str, optional
351 OpenAIのモデル名(通常のChat Completions API用、例: "gpt-4o")。
352 :param openai_model5: str, optional
353 OpenAIの仮のResponses API用モデル名(例: "gpt-5.2")。
354 :param gemini_key: str, optional
355 Google Gemini APIキー。
356 :param gemini_model: str, optional
357 Google Geminiのモデル名(例: "gemini-2.5-flash")。
358 :returns: str or None
359 AIからの応答テキスト。API呼び出し中にエラーが発生した場合はNone。
360 :raises ValueError: APIキーが未設定の場合、または未対応のAPIが指定された場合。
361 """
362
363 model_name = ""
364 try:
365 if api_choice in ('gemini', 'google'):
366 model_name = gemini_model
367 if not gemini_key: raise ValueError("GOOGLE_API_KEY が未設定です。")
368
369 print(f"🚀 Gemini API [{model_name}] を呼び出しています...")
370 genai.configure(api_key=gemini_key)
371 model = genai.GenerativeModel(model_name)
372
373 # Gemini形式のメッセージリストに変換: 'content' -> 'parts'
374 # Geminiはロールとして'user'と'model'を期待し、コンテンツは'parts'リストに格納される
375 # build_messagesで既にuserメッセージに統合されているため、userロールとして渡す
376 gemini_messages = []
377 for m in messages:
378 # build_messages関数でシステム指示がすでにユーザーメッセージに統合されていることを前提とする
379 if m["role"] == "system":
380 # ここに到達することはbuild_messagesのロジック上は通常ないが、念のため
381 gemini_messages.append({"role": "user", "parts": [m["content"]]})
382 else:
383 gemini_messages.append({"role": m["role"], "parts": [m["content"]]})
384
385
386 response = model.generate_content(gemini_messages)
387 return response.text
388
389 elif api_choice == 'openai5':
390 model_name = openai_model5
391 if not openai_key: raise ValueError("OPENAI_API_KEY が未設定です。")
392
393 print(f"🚀 OpenAI Responses API (openai5) [{model_name}] を呼び出しています...")
394 client = OpenAI(api_key=openai_key)
395 # このAPIがメッセージリスト形式を受け付けると仮定
396 # 実際にはOpenAIのResponses APIは一般的に使用されるChat Completions APIとは異なるインターフェースを持つ可能性があります
397 response = client.responses.create(
398 model=model_name,
399 input=messages
400 )
401 return response.output_text or ""
402
403 elif api_choice == 'openai':
404 model_name = openai_model
405 if not openai_key: raise ValueError("OPENAI_API_KEY が未設定です。")
406
407 print(f"🚀 OpenAI Chat Completions API [{model_name}] を呼び出しています...")
408 client = OpenAI(api_key=openai_key)
409 response = client.chat.completions.create(
410 model=model_name,
411 messages=messages
412 )
413 return response.choices[0].message.content
414
415 else:
416 raise ValueError(f"未対応API: {api_choice}")
417
418 except Exception as e:
419 print(f"❌ API呼び出し中にエラーが発生しました ({api_choice}/{model_name}): {e}", file=sys.stderr)
420 return None
421
422def run_ai_processing(infile: str, in_slide_file: str, textbook_file: str, slide_file: str, args):
423 """
424 AIを活用して講義テキストとスライドから教科書とスライドのMarkdownを生成します。
425
426 詳細説明:
427 入力された文字起こしテキストとスライドMarkdownを読み込みます。
428 選択された出力言語、専門分野、役割に基づき、AIへのプロンプトを構築します。
429 構築されたメッセージリストを`build_messages`関数でAI APIに応じた形式に変換し、
430 `save_messages_log`関数でログファイルに保存します。
431 `call_ai_api`関数を呼び出してAIに処理を依頼し、その応答から教科書とスライドの内容を抽出し、
432 それぞれ指定された出力ファイルにMarkdown形式で保存します。
433
434 :param infile: str or None
435 文字起こしテキストファイルのパス。Noneの場合、テキストは提供されません。
436 :param in_slide_file: str or None
437 入力スライドMarkdownファイルのパス。Noneの場合、スライドは提供されません。
438 :param textbook_file: str
439 出力する教科書Markdownファイルのパス。
440 :param slide_file: str
441 出力するスライドMarkdownファイルのパス。
442 :param args: argparse.Namespace
443 `argparse`によって解析されたコマンドライン引数オブジェクト。
444 (例: `args.api`, `args.lang`, `args.field`, `args.role`など)
445 :returns: なし
446 :raises FileNotFoundError: 入力ファイルが見つからない場合。
447 """
448
449 print()
450 print(f"生成AIを実行します...")
451 print(f" 文字起こし入力: {infile}")
452 print(f" スライド入力 : {in_slide_file}")
453 print(f" 出力言語: {args.lang}")
454 print()
455
456 if infile:
457 try:
458 lecture_text = Path(infile).read_text(encoding='utf-8')
459 except FileNotFoundError:
460 print(f"❌ 入力ファイル '{infile}' が見つかりません", file=sys.stderr)
461 terminate()
462 else:
463 lecture_text = None
464
465 slide_markdown = ""
466 if in_slide_file:
467 try:
468 slide_markdown = Path(in_slide_file).read_text(encoding='utf-8')
469 print(f"📄 スライド入力: {in_slide_file}")
470 except FileNotFoundError:
471 print(f"⚠️ スライド '{in_slide_file}' が見つかりません", file=sys.stderr)
472 terminate()
473
474 if args.lang == 'en':
475 language = '米国英語'
476 if PROMPT_TEMPLATE_EN:
477 prompt_template = PROMPT_TEMPLATE_EN
478 else:
479 print(f"Error: PROMPT_TEMPLATE_EN is not provided for lang=en\n")
480 terminate()
481 elif args.lang == 'ja':
482 language = '日本語'
483 prompt_template = PROMPT_TEMPLATE_JA
484 elif args.lang == 'zh':
485 language = '標準中国語'
486 prompt_template = PROMPT_TEMPLATE_JA
487 elif args.lang == 'ko':
488 language = '韓国語'
489 prompt_template = PROMPT_TEMPLATE_JA
490 else:
491 if args.lang in language_map.keys():
492 language = language_map[args.lang]
493 prompt_template = PROMPT_TEMPLATE_JA
494 else:
495 print(f"Error: Invalid lang={args.lang}\n")
496 terminate()
497
498 prompt = prompt_template\
499 .replace("{field}", args.field)\
500 .replace("{role}", args.role)\
501 .replace("{language}", language)\
502 .strip()
503# print(" prompt:", prompt)
504 system_instructions = prompt
505
506 final_instruction = """
507すべての情報を統合し、以下の形式で出力してください:
508
509[TEXTBOOK_START]
510(教科書の内容)
511[TEXTBOOK_END]
512[SLIDES_START]
513(スライドの内容)
514[SLIDES_END]
515"""
516
517 messages = build_messages(args.api, system_instructions, lecture_text, slide_markdown, final_instruction)
518
519 # 🔹 messagesログを保存
520 save_messages_log(messages, infile)
521
522 print("\n--- 🤖 AIにプロンプトを送信 ---")
523
524 ai_response = call_ai_api(
525 messages,
526 args.api,
527 openai_key=args.openai_key,
528 openai_model=args.openai_model,
529 openai_model5=args.openai_model5,
530 gemini_key=args.gemini_key,
531 gemini_model=args.google_model,
532 )
533
534 if not ai_response:
535 print("❌ Error: 生成AIからの応答が得られません", file=sys.stderr)
536 terminate()
537
538 textbook_match = re.search(r"\[TEXTBOOK_START\](.*?)\[TEXTBOOK_END\]", ai_response, re.DOTALL)
539 slides_match = re.search(r"\[SLIDES_START\](.*?)\[SLIDES_END\]", ai_response, re.DOTALL)
540
541 if not textbook_match or not slides_match:
542 print("❌ Error: 出力形式エラー", file=sys.stderr)
543 print(ai_response, file=sys.stderr)
544 terminate()
545
546 Path(textbook_file).write_text(textbook_match.group(1).strip(), encoding='utf-8')
547 print(f"✅ 教科書ファイル '{textbook_file}' を生成")
548 Path(slide_file).write_text(slides_match.group(1).strip(), encoding='utf-8')
549 print(f"✅ スライドファイル '{slide_file}' を生成")
550
551
552def main():
553 """
554 プログラムのエントリポイント。コマンドライン引数を解析し、AI処理を実行します。
555
556 詳細説明:
557 この関数は、プログラムの実行開始時に呼び出されます。
558 まず`parse_args`関数を呼び出してコマンドライン引数を解析し、必要な設定(入力・出力ファイル名、API設定など)を取得します。
559 次に、デフォルトの出力ファイル名が設定されていない場合は、入力ファイル名に基づいて生成します。
560 最後に、`run_ai_processing`関数を呼び出して、AIによる主要なテキストとスライドの生成処理を実行します。
561 処理が完了した後、`terminate`関数を呼び出してプログラムを終了します。
562
563 :returns: なし
564 """
565 global pause
566
567 parser, args = parse_args()
568 pause = args.pause
569 base = Path(args.infile).stem if args.infile else "output"
570 textbook = args.textbook or f"{base}_textbook.md"
571 slide = args.slide or f"{base}_slide.md"
572
573 run_ai_processing(args.infile, args.in_slide, textbook, slide, args)
574
575 terminate()
576
577
578if __name__ == "__main__":
579 main()
580 terminate()