ai.make_textbook5 のソースコード

#!/usr/bin/env python3 
# -*- coding: utf-8 -*-
"""
AI処理専用:講義テキスト(文字起こし)とスライドMarkdownから教科書用/スライド用Markdownを生成します。

AIを活用して、講義の文字起こしテキストとMarkdown形式のスライドを基に、
教育用の教科書Markdownと、より洗練されたスライド用Markdownを自動生成します。
OpenAI (GPTシリーズ) とGoogle (Geminiシリーズ) の両APIに対応しており、
それぞれ最適なメッセージ構築形式を自動で選択します。
AIへの入力となるメッセージはデバッグ用に`.log`ファイルとして保存されます。

- OpenAI: system + user の会話形式でメッセージを構築します。
- Google: 1つのuserメッセージにすべての情報をまとめる形式でメッセージを構築します。
- AIに渡すmessagesを .log ファイルに保存します。

:doc:`make_textbook5_usage`
"""

import os
import sys
import argparse
import re
import json
from pathlib import Path

try:
    import google.generativeai as genai
    from openai import OpenAI
except ImportError:
    print("必要なライブラリがインストールされていません。", file=sys.stderr)
    print("pip install google-generativeai openai python-dotenv を実行してください。", file=sys.stderr)
    input("\nPress ENTER to terminate>>\n")
    sys.exit(1)

from tkai_lib import read_ai_config


PROMPT_TEMPLATE_JA = None
PROMPT_TEMPLATE_EN = None

language_map = {
    'jp': '日本語',
    'ja': '日本語',
    'en': '米国英語',
    'cn': '標準中国語',
    'zh': '標準中国語',
    'kr': '韓国語',
    'ko': '韓国語',
    }

pause = 0


[ドキュメント] def terminate(): """ プログラムを終了します。 詳細説明: グローバル変数`pause`が真(1)の場合、ユーザーにEnterキーの入力を促してからプログラムを終了します。 これにより、コンソールウィンドウがすぐに閉じられるのを防ぎ、出力の確認を可能にします。 :returns: なし """ if pause: input("\nPress ENTER to terminate>>\n") exit()
[ドキュメント] def search_file(infile=None): """ 指定されたファイルをカレントディレクトリまたはスクリプトディレクトリから検索します。 詳細説明: `infile`がNoneの場合、スクリプト名に基づいたデフォルトのINIファイル名(例: `make_textbook5.ini`)を生成します。 このデフォルトファイルは、まずカレントディレクトリで検索され、次に見つからなければスクリプトが配置されているディレクトリで検索されます。 `infile`が指定された場合、そのパスが存在するかを確認し、存在しない場合はスクリプトディレクトリ内での存在も確認します。 :param infile: str, optional 検索するファイルのパス。Noneの場合、デフォルトのINIファイルを検索します。 :returns: str or None 見つかったファイルの絶対パス。見つからない場合はNone。 """ script_path = os.path.abspath(sys.argv[0]) script_dir = os.path.dirname(script_path) script_name = os.path.splitext(os.path.basename(script_path))[0] default_ini = f"{script_name}.ini" if infile is None: for path in [os.getcwd(), script_dir]: candidate = os.path.join(path, default_ini) if os.path.isfile(candidate): return candidate return None if not os.path.isfile(infile): candidate = os.path.join(script_dir, infile) if os.path.isfile(candidate): return candidate return None return infile
[ドキュメント] def read_ini(inifile=None): """ INIファイルから設定を読み込み、キーと値の辞書として返します。 詳細説明: 指定されたINIファイルを検索し、読み込みます。 コメント行(`#`または`;`で始まる行)および空行はスキップされます。 `key=value`形式の行を解析し、辞書に格納します。 三重引用符で囲まれた複数行の値をサポートします。 読み込み後、辞書内の値に対して`$VAR`形式の変数展開を行います。 :param inifile: str, optional 読み込むINIファイルのパス。Noneの場合、`search_file`関数でデフォルトのINIファイルを検索します。 :returns: dict INIファイルから読み込まれた設定を格納した辞書。 :raises FileNotFoundError: 指定されたINIファイルが見つからない場合。 """ path = search_file(inifile) if path is None: raise FileNotFoundError("INIファイルが見つかりませんでした") result = {} variables = {} current_key = None multiline_val = [] multiline_delim = None with open(path, 'r', encoding='utf-8') as f: for line in f: line = line.rstrip() if not line or line.startswith('#') or line.startswith(';'): continue # 複数行値の終了判定(stripで判定) if multiline_delim: if line.strip() == multiline_delim: val = '\n'.join(multiline_val) result[current_key] = val variables[current_key] = val current_key = None multiline_val = [] multiline_delim = None else: multiline_val.append(line) continue # key=val の解析 if '=' in line: key, val = map(str.strip, line.split('=', 1)) val = val.strip() # 複数行値の開始判定(空文字でも対応) if (val == '"""' or val == "'''" or (val.startswith('"""') and not val.endswith('"""')) or \ (val.startswith("'''") and not val.endswith("'''")) ): multiline_delim = val[:3] content = val[3:] multiline_val = [content] if content else [] current_key = key continue # 単一行の複数行値 if (val.startswith('"""') and val.endswith('"""')) or \ (val.startswith("'''") and val.endswith("'''")): val = val[3:-3] result[key] = val variables[key] = val # 変数展開(あとから一括処理) for key, val in result.items(): def expand_var(match): var_name = match.group(1) return variables.get(var_name, match.group(0)) result[key] = re.sub(r"\$(\w+)\b", expand_var, val) return result
[ドキュメント] def parse_args(): """ コマンドライン引数を解析し、AI設定ファイルを読み込みます。 詳細説明: `argparse`モジュールを使用して、入力ファイル、出力ファイル、使用するAI API、 モデル名、出力言語、専門分野、役割などのコマンドライン引数を解析します。 AIの設定は`ai.env`から`read_ai_config`で読み込まれ、APIキーは環境変数から取得されます。 プロンプトテンプレートはINIファイルから読み込まれ、グローバル変数`PROMPT_TEMPLATE_JA`と`PROMPT_TEMPLATE_EN`に設定されます。 必要な入力ファイルが指定されていない場合はエラーを出力し、プログラムを終了します。 :returns: tuple (argparse.ArgumentParser, argparse.Namespace) `argparse.ArgumentParser`オブジェクトと、解析された引数を格納する`argparse.Namespace`オブジェクトのタプル。 """ global PROMPT_TEMPLATE_JA, PROMPT_TEMPLATE_EN read_ai_config('ai.env') p = argparse.ArgumentParser( description="講義の文字起こしとスライドをAIで教科書/スライドMarkdownに変換(Pandoc不要)。", formatter_class=argparse.RawTextHelpFormatter ) p.add_argument('--inifile', default = None, help='プロンプトなどを保存したkey=valファイル') p.add_argument('-i', '--infile', default = None, help='文字起こしテキストファイル (例: lecture.txt)') p.add_argument('-im', '--in_slide', default = None, help='入力 講義スライドMarkdown (任意, 例: slide.md)') p.add_argument('-t', '--textbook', help='出力 教科書Markdown (デフォルト: [infile]_textbook.md)') p.add_argument('-s', '--slide', help='出力 スライドMarkdown (デフォルト: [infile]_slide.md)') ai = p.add_argument_group('AI設定') ai.add_argument('--api', '-a', choices=['gemini', 'openai5', 'openai', 'google'], default='gemini', help='使用API') ai.add_argument('--model', help='明示モデル名の指定(apiごとに適用先を切替)') ai.add_argument('--openai_model', default=os.getenv("OPENAI_MODEL", "gpt-4o")) ai.add_argument('--openai_model5', default=os.getenv("OPENAI_MODEL5", "gpt-5.2")) # ai.add_argument('--google_model', default=os.getenv("GOOGLE_MODEL", "gemini-3-preview")) ai.add_argument('--google_model', default=os.getenv("GOOGLE_MODEL", "gemini-2.5-flash")) ai.add_argument('--lang', default='ja', choices=['ja', 'en', 'zh', 'ko'], help='出力言語 (デフォルト ja)') ai.add_argument('--field', default='半導体工学', help='専門分野') ai.add_argument('--role', default='大学教授', help='役割') ai.add_argument('--pause', type=int, default=0, help="終了時にENTERキー入力を要求するか (デフォルト: 0)") args = p.parse_args() if args.infile is None and args.in_slide is None: print("❌ --infile(-i)か--in_slide(-im)のどちらかを与えないといけません") terminate() args.openai_key = os.getenv("OPENAI_API_KEY") args.gemini_key = os.getenv("GOOGLE_API_KEY") if args.model: if args.api == 'openai5': args.openai_model5 = args.model elif args.api == 'openai': args.openai_model = args.model elif args.api in ('gemini', 'google'): args.google_model = args.model args.inifile = search_file(args.inifile) print("Prompot inifile: ", args.inifile) inf = read_ini(args.inifile) PROMPT_TEMPLATE_JA = inf["PROMPT_TEMPLATE_JA"] PROMPT_TEMPLATE_EN = inf["PROMPT_TEMPLATE_EN"] return p, args
[ドキュメント] def build_messages(api_choice: str, system_instructions: str, lecture_text: str, slide_markdown: str, final_instruction: str): """ 指定されたAIプラットフォーム向けにメッセージリストを構築します。 詳細説明: AI APIの種類(OpenAIまたはGoogle Gemini)に応じて、適切な形式でメッセージリストを構築します。 OpenAI (gpt-4oなど) および Open AI Responses (仮のopenai5) の場合は、 `system`ロールと複数の`user`ロールで構成される会話形式を採用します。 Google Gemini (gemini-2.5-flashなど) の場合は、すべての情報を1つの`user`メッセージにまとめます。 :param api_choice: str 使用するAI APIの選択肢 ('openai', 'openai5', 'gemini', 'google')。 :param system_instructions: str AIへのシステム指示(プロンプト)。 :param lecture_text: str or None 講義の文字起こしテキスト。Noneの場合は「文字起こしテキストはありません。」と伝えます。 :param slide_markdown: str スライドのMarkdownテキスト。空文字の場合はスライドは提供されません。 :param final_instruction: str 最終的な出力形式に関する指示。 :returns: list of dict AIに渡すためのメッセージリスト。 """ messages = [] if api_choice in ('openai', 'openai5'): # OpenAI形式 messages.append({"role": "system", "content": system_instructions}) if lecture_text: messages.append({"role": "user", "content": f"# 文字起こしテキスト\n\n{lecture_text}"}) else: messages.append({"role": "user", "content": "文字起こしテキストはありません。"}) if slide_markdown: messages.append({"role": "user", "content": f"# 講義スライド\n\n{slide_markdown}"}) messages.append({"role": "user", "content": final_instruction}) elif api_choice in ('gemini', 'google'): # Google/Gemini形式: まとめる parts = [] parts.append(system_instructions) if lecture_text: parts.append(f"# 文字起こしテキスト\n\n{lecture_text}") else: parts.append("文字起こしテキストはありません。") if slide_markdown: parts.append(f"# 講義スライド\n\n{slide_markdown}") parts.append(final_instruction) messages = [{"role": "user", "content": "\n\n".join(parts)}] return messages
[ドキュメント] def save_messages_log(messages: list, infile: str): """ AIとの対話メッセージリストをJSON形式でログファイルに保存します。 詳細説明: `infile`のファイル名(拡張子なし)を基にログファイル名(例: `lecture.log`)を生成します。 メッセージリストは、デバッグや検証のために読みやすい整形されたJSON形式で保存されます。 ファイル保存中にエラーが発生した場合は、エラーメッセージを標準エラー出力に表示します。 :param messages: list of dict 保存するメッセージリスト。 :param infile: str 元の入力ファイルのパス(ログファイル名生成用)。 Noneの場合、"output.log"が生成されます。 :returns: なし """ if infile: base = Path(infile).stem else: base = "output" log_file = f"{base}.log" try: with open(log_file, "w", encoding="utf-8") as f: json.dump(messages, f, ensure_ascii=False, indent=2) print(f"📝 messagesログを '{log_file}' に保存しました") except Exception as e: print(f"⚠️ ログ保存に失敗しました: {e}", file=sys.stderr)
[ドキュメント] def call_ai_api(messages: list, api_choice: str, *, openai_key=None, openai_model=None, openai_model5=None, gemini_key=None, gemini_model=None) -> str: """ 指定されたAIサービス(OpenAIまたはGoogle Gemini)のAPIを呼び出します。 詳細説明: `api_choice`に基づいて、適切なAPIクライアントとモデルを選択し、AIにメッセージを送信します。 OpenAI API (`openai`) は`client.chat.completions.create`を使用します。 OpenAI Responses API (`openai5`) は、仮の`client.responses.create`を使用することを想定しています。 Google Gemini API (`gemini`, `google`) は`genai.GenerativeModel`と`model.generate_content`を使用します。 APIキーが設定されていない場合や、未対応のAPIが指定された場合はエラーを発生させます。 :param messages: list of dict AIに送信するメッセージリスト。 :param api_choice: str 使用するAI APIの選択肢 ('openai', 'openai5', 'gemini', 'google')。 :param openai_key: str, optional OpenAI APIキー。 :param openai_model: str, optional OpenAIのモデル名(通常のChat Completions API用、例: "gpt-4o")。 :param openai_model5: str, optional OpenAIの仮のResponses API用モデル名(例: "gpt-5.2")。 :param gemini_key: str, optional Google Gemini APIキー。 :param gemini_model: str, optional Google Geminiのモデル名(例: "gemini-2.5-flash")。 :returns: str or None AIからの応答テキスト。API呼び出し中にエラーが発生した場合はNone。 :raises ValueError: APIキーが未設定の場合、または未対応のAPIが指定された場合。 """ model_name = "" try: if api_choice in ('gemini', 'google'): model_name = gemini_model if not gemini_key: raise ValueError("GOOGLE_API_KEY が未設定です。") print(f"🚀 Gemini API [{model_name}] を呼び出しています...") genai.configure(api_key=gemini_key) model = genai.GenerativeModel(model_name) # Gemini形式のメッセージリストに変換: 'content' -> 'parts' # Geminiはロールとして'user'と'model'を期待し、コンテンツは'parts'リストに格納される gemini_messages = [] for m in messages: if m["role"] == "system": # システムメッセージはGeminiではuserメッセージの先頭に統合されることが多い # ここでは一旦userロールとして扱うか、別途プロンプトで処理する必要がある # build_messagesで既にuserメッセージに統合されているため、そのまま渡す gemini_messages.append({"role": "user", "parts": [m["content"]]}) else: gemini_messages.append({"role": m["role"], "parts": [m["content"]]}) response = model.generate_content(gemini_messages) return response.text elif api_choice == 'openai5': model_name = openai_model5 if not openai_key: raise ValueError("OPENAI_API_KEY が未設定です。") print(f"🚀 OpenAI Responses API (openai5) [{model_name}] を呼び出しています...") client = OpenAI(api_key=openai_key) # このAPIがメッセージリスト形式を受け付けると仮定 # 実際にはOpenAIのResponses APIは一般的に使用されるChat Completions APIとは異なるインターフェースを持つ可能性があります response = client.responses.create( model=model_name, input=messages ) return response.output_text or "" elif api_choice == 'openai': model_name = openai_model if not openai_key: raise ValueError("OPENAI_API_KEY が未設定です。") print(f"🚀 OpenAI Chat Completions API [{model_name}] を呼び出しています...") client = OpenAI(api_key=openai_key) response = client.chat.completions.create( model=model_name, messages=messages ) return response.choices[0].message.content else: raise ValueError(f"未対応API: {api_choice}") except Exception as e: print(f"❌ API呼び出し中にエラーが発生しました ({api_choice}/{model_name}): {e}", file=sys.stderr) return None
[ドキュメント] def run_ai_processing(infile: str, in_slide_file: str, textbook_file: str, slide_file: str, args): """ AIを活用して講義テキストとスライドから教科書とスライドのMarkdownを生成します。 詳細説明: 入力された文字起こしテキストとスライドMarkdownを読み込みます。 選択された出力言語、専門分野、役割に基づき、AIへのプロンプトを構築します。 構築されたメッセージリストを`build_messages`関数でAI APIに応じた形式に変換し、 `save_messages_log`関数でログファイルに保存します。 `call_ai_api`関数を呼び出してAIに処理を依頼し、その応答から教科書とスライドの内容を抽出し、 それぞれ指定された出力ファイルにMarkdown形式で保存します。 :param infile: str or None 文字起こしテキストファイルのパス。Noneの場合、テキストは提供されません。 :param in_slide_file: str or None 入力スライドMarkdownファイルのパス。Noneの場合、スライドは提供されません。 :param textbook_file: str 出力する教科書Markdownファイルのパス。 :param slide_file: str 出力するスライドMarkdownファイルのパス。 :param args: argparse.Namespace `argparse`によって解析されたコマンドライン引数オブジェクト。 (例: `args.api`, `args.lang`, `args.field`, `args.role`など) :returns: なし :raises FileNotFoundError: 入力ファイルが見つからない場合。 """ print() print(f"生成AIを実行します...") print(f" 文字起こし入力: {infile}") print(f" スライド入力 : {in_slide_file}") print(f" 出力言語: {args.lang}") print() if infile: try: lecture_text = Path(infile).read_text(encoding='utf-8') except FileNotFoundError: print(f"❌ 入力ファイル '{infile}' が見つかりません", file=sys.stderr) terminate() else: lecture_text = None slide_markdown = "" if in_slide_file: try: slide_markdown = Path(in_slide_file).read_text(encoding='utf-8') print(f"📄 スライド入力: {in_slide_file}") except FileNotFoundError: print(f"⚠️ スライド '{in_slide_file}' が見つかりません", file=sys.stderr) terminate() if args.lang == 'en': language = '米国英語' if PROMPT_TEMPLATE_EN: prompt_template = PROMPT_TEMPLATE_EN else: print(f"Error: PROMPT_TEMPLATE_EN is not provided for lang=en\n") terminate() elif args.lang == 'ja': language = '日本語' prompt_template = PROMPT_TEMPLATE_JA elif args.lang == 'zh': language = '標準中国語' prompt_template = PROMPT_TEMPLATE_JA elif args.lang == 'ko': language = '韓国語' prompt_template = PROMPT_TEMPLATE_JA else: if args.lang in language_map.keys(): language = language_map[args.lang] prompt_template = PROMPT_TEMPLATE_JA else: print(f"Error: Invalid lang={args.lang}\n") terminate() prompt = prompt_template\ .replace("{field}", args.field)\ .replace("{role}", args.role)\ .replace("{language}", language)\ .strip() # print(" prompt:", prompt) system_instructions = prompt final_instruction = """ すべての情報を統合し、以下の形式で出力してください: [TEXTBOOK_START] (教科書の内容) [TEXTBOOK_END] [SLIDES_START] (スライドの内容) [SLIDES_END] """ messages = build_messages(args.api, system_instructions, lecture_text, slide_markdown, final_instruction) # 🔹 messagesログを保存 save_messages_log(messages, infile) print("\n--- 🤖 AIにプロンプトを送信 ---") ai_response = call_ai_api( messages, args.api, openai_key=args.openai_key, openai_model=args.openai_model, openai_model5=args.openai_model5, gemini_key=args.gemini_key, gemini_model=args.google_model, ) if not ai_response: print("❌ Error: 生成AIからの応答が得られません", file=sys.stderr) terminate() textbook_match = re.search(r"\[TEXTBOOK_START\](.*?)\[TEXTBOOK_END\]", ai_response, re.DOTALL) slides_match = re.search(r"\[SLIDES_START\](.*?)\[SLIDES_END\]", ai_response, re.DOTALL) if not textbook_match or not slides_match: print("❌ Error: 出力形式エラー", file=sys.stderr) print(ai_response, file=sys.stderr) terminate() Path(textbook_file).write_text(textbook_match.group(1).strip(), encoding='utf-8') print(f"✅ 教科書ファイル '{textbook_file}' を生成") Path(slide_file).write_text(slides_match.group(1).strip(), encoding='utf-8') print(f"✅ スライドファイル '{slide_file}' を生成")
[ドキュメント] def main(): """ プログラムのエントリポイント。コマンドライン引数を解析し、AI処理を実行します。 詳細説明: この関数は、プログラムの実行開始時に呼び出されます。 まず`parse_args`関数を呼び出してコマンドライン引数を解析し、必要な設定(入力・出力ファイル名、API設定など)を取得します。 次に、デフォルトの出力ファイル名が設定されていない場合は、入力ファイル名に基づいて生成します。 最後に、`run_ai_processing`関数を呼び出して、AIによる主要なテキストとスライドの生成処理を実行します。 処理が完了した後、`terminate`関数を呼び出してプログラムを終了します。 :returns: なし """ global pause parser, args = parse_args() pause = args.pause base = Path(args.infile).stem if args.infile else "output" textbook = args.textbook or f"{base}_textbook.md" slide = args.slide or f"{base}_slide.md" run_ai_processing(args.infile, args.in_slide, textbook, slide, args) terminate()
if __name__ == "__main__": main() terminate()