make_textbook5.py ダウンロード/コピー

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()