syurein commited on
Commit
67041f9
·
1 Parent(s): fa65b5b
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # 環境変数設定
4
+ ENV PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=off \
6
+ PIP_DISABLE_PIP_VERSION_CHECK=on \
7
+ PIP_DEFAULT_TIMEOUT=100 \
8
+ POETRY_VERSION=1.6.1
9
+
10
+ # 必要なパッケージをインストール
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ gcc \
13
+ libpq-dev \
14
+ ffmpeg \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # 作業ディレクトリの作成
18
+ WORKDIR /app
19
+
20
+ # 依存関係のインストール
21
+ COPY requirements.txt ./
22
+ RUN pip install --no-cache-dir -r requirements.txt
23
+
README.md CHANGED
Binary files a/README.md and b/README.md differ
 
app.py ADDED
@@ -0,0 +1,1164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import os
3
+ import time
4
+ import random
5
+ from flask import Flask, render_template, request, jsonify
6
+ import yt_dlp
7
+ import requests # requestsライブラリをインポート
8
+ import base64
9
+ # from google import genai # genai を直接インポートする代わりに google.generativeai を使う
10
+ import google.generativeai as genai
11
+ import json
12
+ import time
13
+ import os
14
+ import re
15
+ import random
16
+ import logging
17
+ import traceback
18
+
19
+ # --- アプリケーション設定 ---
20
+ app = Flask(__name__)
21
+ # セキュリティのため、実際の運用では環境変数などから読み込むことを推奨
22
+ app.secret_key = os.getenv('FLASK_SECRET_KEY', os.urandom(24))
23
+
24
+ # --- GAS Web App URL ---
25
+ # 環境変数から取得するか、直接記述(テスト用)
26
+ GAS_WEB_APP_URL = os.getenv('GAS_WEB_APP_URL', 'https://script.google.com/macros/s/AKfycbzcOZSPIvKq__QQJlMH5wBgnjjiio-vgtCpNrxAYO5hE3LVIY42I0GsGFO32hwraV4g/exec') # 環境変数から取得、なければプレースホルダ
27
+
28
+ # --- ログ設定 ---
29
+ logging.basicConfig(
30
+ level=logging.DEBUG, # 開発中はDEBUG、本番ではINFOなどに変更
31
+ format="%(asctime)s [%(levelname)s] [%(filename)s:%(lineno)d] %(message)s",
32
+ handlers=[
33
+ logging.StreamHandler() # コンソールに出力
34
+ # Hugging Face Spacesではファイルへの永続的な書き込みは制限されるため、ファイルハンドラはコメントアウト推奨
35
+ # logging.FileHandler(os.path.join('/tmp', "app.log"), encoding='utf-8') # <<< 修正 >>> ログファイルパス変更 (もし使う場合)
36
+ ]
37
+ )
38
+ logging.getLogger('urllib3').setLevel(logging.WARNING) # requestsライブラリのログレベル調整
39
+ logging.getLogger('google').setLevel(logging.WARNING) # googleライブラリのログレベル調整
40
+ logging.getLogger('yt_dlp').setLevel(logging.INFO) # yt-dlpのログレベルをINFOに設定 (DEBUGだと多すぎる可能性)
41
+
42
+ # --- 一時ディレクトリ定義 ---
43
+ # <<< 修正 >>> Hugging Face Spacesで利用可能な一時ディレクトリ
44
+ TEMP_DIR = '/tmp'
45
+
46
+ # --- バックエンド処理関数 ---
47
+
48
+ def download_and_extract_audio(youtube_url):
49
+ """
50
+ yt-dlpを使って動画をダウンロードし、音声をMP3形式で抽出する。
51
+ 成功したら (音声ファイルパス, 動画情報) のタプルを、失敗したら (None, None) を返す。
52
+ """
53
+ # <<< 修正 >>> 一時ディレクトリを使用
54
+ output_dir = os.path.join(TEMP_DIR, 'downloads')
55
+ os.makedirs(output_dir, exist_ok=True)
56
+ logging.debug(f"音声保存ディレクトリ: {os.path.abspath(output_dir)}")
57
+
58
+ output_template = os.path.join(output_dir, '%(id)s.%(ext)s')
59
+
60
+ ydl_opts = {
61
+ 'format': 'bestaudio/best',
62
+ 'postprocessors': [{
63
+ 'key': 'FFmpegExtractAudio',
64
+ 'preferredcodec': 'mp3',
65
+ 'preferredquality': '192',
66
+ }],
67
+ 'outtmpl': output_template,
68
+ 'noplaylist': True,
69
+ 'logger': logging.getLogger('yt_dlp'),
70
+ 'verbose': False,
71
+ }
72
+
73
+ audio_file_path = None
74
+ info_dict_result = None
75
+
76
+ try:
77
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
78
+ logging.info(f"yt-dlp: {youtube_url} のダウンロードと音声抽出を開始")
79
+ info_dict = ydl.extract_info(youtube_url, download=True)
80
+ video_id = info_dict.get('id', 'unknown_id')
81
+ # 動画情報も返すようにする
82
+ info_dict_result = {
83
+ 'id': video_id,
84
+ 'title': info_dict.get('title', f'動画 {video_id}'),
85
+ 'thumbnail': info_dict.get('thumbnail'), # サムネイルURL
86
+ 'uploader': info_dict.get('uploader'),
87
+ 'duration': info_dict.get('duration'),
88
+ }
89
+ logging.info(f"yt-dlp: 動画情報取得完了 (ID: {video_id}, Title: {info_dict_result['title']})")
90
+
91
+ base_filename = ydl.prepare_filename(info_dict)
92
+ # yt-dlp の prepare_filename は絶対パスを返すことがあるので、
93
+ # 確実に /tmp 配下のパスにするため、ファイル名だけ取得して結合する
94
+ # expected_mp3_path = os.path.splitext(base_filename)[0] + '.mp3'
95
+ expected_mp3_filename = os.path.splitext(os.path.basename(base_filename))[0] + '.mp3'
96
+ expected_mp3_path = os.path.join(output_dir, expected_mp3_filename)
97
+ logging.debug(f"yt-dlp: 期待されるMP3ファイルパス -> {expected_mp3_path}")
98
+
99
+
100
+ wait_time = 0
101
+ max_wait = 15 # 少し長めに待つ (15秒) / 環境によっては時間がかかる場合あり
102
+ while not os.path.exists(expected_mp3_path) and wait_time < max_wait:
103
+ logging.debug(f"MP3ファイル待機中: {expected_mp3_path} (待機時間: {wait_time}秒)")
104
+ time.sleep(1)
105
+ wait_time += 1
106
+
107
+ if os.path.exists(expected_mp3_path):
108
+ audio_file_path = expected_mp3_path
109
+ logging.info(f"yt-dlp: 音声抽出完了 -> {audio_file_path}")
110
+ return audio_file_path, info_dict_result
111
+ else:
112
+ logging.warning(f"期待されたMP3ファイルが見つかりません: {expected_mp3_path}")
113
+ # output_dir 内のファイルをリストアップして確認
114
+ potential_files = [f for f in os.listdir(output_dir) if f.startswith(video_id) and f.endswith('.mp3')]
115
+ if potential_files:
116
+ # output_dirを基準に絶対パスを生成
117
+ audio_file_path = os.path.join(output_dir, potential_files[0])
118
+ logging.info(f"yt-dlp: 代替検索で見つかった音声ファイル -> {audio_file_path}")
119
+ return audio_file_path, info_dict_result
120
+ else:
121
+ logging.error("yt-dlp: 音声抽出後のMP3ファイル特定に失敗しました。")
122
+ logging.error(f"Downloads directory ({output_dir}) contents: {os.listdir(output_dir)}")
123
+ return None, info_dict_result # ファイルパスはNoneだが情報は返す
124
+
125
+ except yt_dlp.utils.DownloadError as e:
126
+ logging.error(f"yt-dlp ダウンロードエラー: {e}")
127
+ if "confirm your age" in str(e).lower():
128
+ logging.error("年齢確認が必要な動画の可能性があります。クッキーファイルの使用を検討してください。")
129
+ elif "video unavailable" in str(e).lower():
130
+ logging.error("動画が利用不可能なようです。")
131
+ elif "Private video" in str(e):
132
+ logging.error("非公開動画のようです。")
133
+ return None, None
134
+ except Exception as e:
135
+ logging.error("yt-dlp: 音声抽出中に予期せぬエラーが発生しました。", exc_info=True)
136
+ return None, None
137
+
138
+ def transcribe_audio(audio_path):
139
+ """
140
+ Google Gemini API を使用して音声ファイルを文字起こし・要約する。
141
+ 成功した場合は、Geminiが生成したテキスト(JSON形式を期待)を返す。
142
+ 失敗した場合は None を返す。
143
+ """
144
+ # 環境変数名は 'GEMINI_API_KEY' を推奨 (コード内と合わせる)
145
+ api_key = os.getenv('GEMINI_API_KEY') # 環境変数名を修正 (より一般的なキー名に)
146
+ if not api_key:
147
+ logging.error("環境変数 'GEMINI_API_KEY' が設定されていません。")
148
+ return None
149
+
150
+ try:
151
+ genai.configure(api_key=api_key)
152
+ except Exception as config_err:
153
+ logging.error(f"Gemini API キーの設定に失敗しました: {config_err}")
154
+ return None
155
+
156
+ audio_file_resource = None # finally で使うため
157
+ try:
158
+ logging.info(f"Gemini: ファイルアップロード開始 - {audio_path}")
159
+ # 大きなファイルの場合、タイムアウト時間を長く設定
160
+ audio_file_resource = genai.upload_file(path=audio_path, request_options={'timeout': 600})
161
+ logging.info(f"Gemini: ファイルアップロード完了 - Name: {audio_file_resource.name}, URI: {audio_file_resource.uri}")
162
+
163
+ # モデル指定を環境変数から取得できるようにする(任意)
164
+ gemini_model_name = os.getenv('GEMINI_MODEL', 'models/gemini-1.5-pro-latest')
165
+ logging.info(f"Gemini: 使用モデル - {gemini_model_name}")
166
+ model = genai.GenerativeModel(gemini_model_name)
167
+
168
+ # プロンプト: 文字起こし、要約、JSON形式での出力を明確に指示 (日本語版)
169
+ prompt_parts = [
170
+ "提供された音声ファイルに対して、以下のタスクを実行してください:",
171
+ "1. 音声の内容を正確にテキストに文字起こししてください。",
172
+ "2. 文字起こし結果に基づき、プレゼンテーションのスライドに適した、論理的なポイントやセクションに分けた簡潔な要約を生成してください。",
173
+ "3. 生成された要約は、**厳密にJSONリスト形式**でフォーマットしてください。JSONリストそのもの以外には、**一切のテキスト(導入文、説明、謝罪、```jsonのようなマークダウン形式など)を含めないでください**。",
174
+ "\n**必須のJSON構造:**",
175
+ "出力は**必ず**有効なJSONリスト `[]` でなければなりません。",
176
+ "リストの各要素は**必ず**以下のキーを持つJSONオブジェクト `{}` でなければなりません:",
177
+ " - `\"id\"`: スライド番号を表す文字列(例: \"s1\", \"s2\", \"s3\")。",
178
+ " - `\"type\"`: 文字列であり、**必ず**正確に `\"summary\"` でなければなりません。",
179
+ " - `\"text\"`: スライドの要約テキストを含む文字列。テキスト内の改行には `\\n` を使用してください。",
180
+ "\n**必須のJSON出力形式の例(この例自体を応答に含めないでください):**",
181
+ '[{"id": "s1", "type": "summary", "text": "This is the first summary point.\\nIt can span multiple lines."}, {"id": "s2", "type": "summary", "text": "This is the second summary point."}]',
182
+ "\nそれでは、以下の音声ファイルを処理してください:",
183
+ audio_file_resource # アップロードしたファイルリソースを渡す
184
+ ]
185
+
186
+ logging.info("Gemini: 文字起こし・要約生成リクエスト送信中...")
187
+ generation_config = genai.types.GenerationConfig(
188
+ temperature=0.5,
189
+ # response_mime_type="application/json" # 期待通り動作しない場合があるためコメントアウト
190
+ )
191
+ safety_settings = [ # デフォルトより緩めに設定 (必要に応じて調整)
192
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_ONLY_HIGH"},
193
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_ONLY_HIGH"},
194
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_ONLY_HIGH"},
195
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_ONLY_HIGH"},
196
+ ]
197
+
198
+ response = model.generate_content(
199
+ prompt_parts,
200
+ generation_config=generation_config,
201
+ safety_settings=safety_settings,
202
+ request_options={'timeout': 600} # 生成自体のタイムアウトも設定
203
+ )
204
+ logging.info("Gemini: 応答受信完了")
205
+
206
+ if not response.candidates or not response.candidates[0].content.parts:
207
+ logging.error("Gemini: 応答に有効なコンテンツが含まれていません。")
208
+ logging.debug(f"Gemini Full Response: {response}")
209
+ # 安全性フィルターによるブロックの可能性を確認
210
+ if response.prompt_feedback and response.prompt_feedback.block_reason:
211
+ logging.error(f"Gemini: プロンプトがブロックされました。理由: {response.prompt_feedback.block_reason}")
212
+ if response.candidates and response.candidates[0].finish_reason != 'STOP':
213
+ logging.error(f"Gemini: 生成が予期せず終了しました。理由: {response.candidates[0].finish_reason}")
214
+ return None
215
+
216
+ generated_text = response.text.strip()
217
+ logging.debug(f"Gemini Generated Text (raw):\n---\n{generated_text}\n---")
218
+
219
+ # 応答からJSONを抽出する試み (```json ... ``` も考慮)
220
+ json_str = None
221
+ match_code_block = re.search(r'```(?:json)?\s*([\s\S]+?)\s*```', generated_text, re.IGNORECASE | re.DOTALL)
222
+ if match_code_block:
223
+ json_str = match_code_block.group(1).strip()
224
+ logging.debug("Gemini: 応答から ```json ... ``` ブロックを抽出しました。")
225
+ else:
226
+ # コードブロックがない場合、全体がJSONか試す
227
+ json_match = re.search(r'^\s*([\[{].*[\]}])\s*$', generated_text, re.DOTALL)
228
+ if json_match:
229
+ json_str = json_match.group(1)
230
+ logging.debug("Gemini: 応答全体がJSON形式の可能性があります。")
231
+ else:
232
+ logging.warning("Gemini: 応答が期待されるJSON形式(コードブロックまたは全体)ではありませんでした。")
233
+
234
+ if json_str:
235
+ try:
236
+ parsed_json = json.loads(json_str)
237
+ if isinstance(parsed_json, list):
238
+ logging.info("Gemini: 応答は期待通りのJSONリスト形式でした。")
239
+ # <<< 修正 >>> デバッグ用ファイルパス変更
240
+ output_filename = os.path.join(TEMP_DIR, "gemini_summary_output.json")
241
+ try:
242
+ with open(output_filename, "w", encoding="utf-8") as f:
243
+ json.dump(parsed_json, f, ensure_ascii=False, indent=2)
244
+ logging.debug(f"Gemini 応答 (JSON) を {output_filename} に保存しました。")
245
+ except IOError as e:
246
+ logging.error(f"Gemini 応答のファイル保存に失敗: {e}")
247
+ return json_str # JSON文字列を返す
248
+ else:
249
+ logging.warning("Gemini: 応答はJSONでしたが、リスト形式ではありませんでした。テキストとして扱います。")
250
+ # JSONだがリストでない場合も、元のテキスト全体を返す方が情報損失が少ないかも
251
+ # return json_str
252
+ except json.JSONDecodeError as e:
253
+ logging.warning(f"Gemini: 抽出したJSON文字列のパースに失敗しました: {e}。応答全体をテキストとして扱います。")
254
+ # パース失敗した場合も、元のテキスト全体を返す
255
+
256
+ # JSONとして処理できなかった場合、元の生成テキスト全体を返す
257
+ logging.warning("Gemini: 応答をJSONリストとして処理できませんでした。応答テキスト全体を返します。")
258
+ # <<< 修正 >>> デバッグ用ファイルパス変更
259
+ output_filename = os.path.join(TEMP_DIR, "gemini_non_json_output.txt")
260
+ try:
261
+ with open(output_filename, "w", encoding="utf-8") as f:
262
+ f.write(generated_text)
263
+ logging.debug(f"Gemini 応答 (非JSON/エラー) を {output_filename} に保存しました。")
264
+ except IOError as e:
265
+ logging.error(f"Gemini 応答のファイル保存に失敗: {e}")
266
+ return generated_text # 元のテキストをそのまま返す
267
+
268
+ except genai.types.generation_types.BlockedPromptException as e:
269
+ logging.error(f"Gemini: プロンプトが安全上の理由でブロックされました: {e}")
270
+ return None
271
+ except genai.types.generation_types.StopCandidateException as e:
272
+ logging.error(f"Gemini: 生成が予期せず停止しました (例: 安全フィルター): {e}")
273
+ return None
274
+ except Exception as e:
275
+ # google.api_core.exceptions.DeadlineExceeded などもここで捕捉
276
+ if "DeadlineExceeded" in str(type(e)):
277
+ logging.error(f"Gemini: API呼び出しがタイムアウトしました: {e}")
278
+ else:
279
+ logging.error(f"Gemini API 呼び出し中に予期せぬエラーが発生しました。", exc_info=True)
280
+ return None
281
+ finally:
282
+ # ファイル削除APIは非同期の場合があるため注意が必要。
283
+ # 現状のライブラリでは delete_file は同期的に見えるが、ドキュメント確認推奨。
284
+ if audio_file_resource:
285
+ try:
286
+ logging.info(f"Gemini: アップロード済みファイル削除試行 - {audio_file_resource.name}")
287
+ genai.delete_file(audio_file_resource.name) # 注意:同期/非同期を確認
288
+ logging.info(f"Gemini: アップロード済みファイル削除完了 - {audio_file_resource.name}")
289
+ except Exception as delete_err:
290
+ logging.error(f"Gemini: アップロード済みファイルの削除中にエラー - {audio_file_resource.name}: {delete_err}", exc_info=True)
291
+
292
+
293
+ # --- OpenAI ライブラリ設定 (DeepSeek API用) ---
294
+ try:
295
+ from openai import OpenAI, APIError, RateLimitError, APITimeoutError, APIConnectionError, AuthenticationError, BadRequestError
296
+ except ImportError:
297
+ logging.error("OpenAIライブラリが見つかりません。`pip install openai` を実行してください。")
298
+ OpenAI = None
299
+ APIError = RateLimitError = APITimeoutError = APIConnectionError = AuthenticationError = BadRequestError = Exception # type: ignore
300
+
301
+ # DeepSeek APIキーとベースURLを環境変数から取得
302
+ api_key_deepseek = os.getenv('DEEPSEEK_API_KEY') # 環境変数名を修正
303
+ base_url_deepseek = os.getenv('DEEPSEEK_BASE_URL', "https://api.deepseek.com/v1")
304
+
305
+ client_deepseek = None
306
+ deepseek_api_initialized = False
307
+
308
+ if not api_key_deepseek:
309
+ logging.warning("環境変数 'DEEPSEEK_API_KEY' が設定されていません。DeepSeek APIは使用できません。")
310
+ elif OpenAI:
311
+ try:
312
+ client_deepseek = OpenAI(
313
+ api_key=api_key_deepseek,
314
+ base_url=base_url_deepseek,
315
+ timeout=120.0, # タイムアウトを延長 (120秒)
316
+ max_retries=1, # ライブラリによるリトライを1回だけ許可 (念のため)
317
+ )
318
+ logging.info(f"DeepSeek APIクライアント初期化完了 (URL: {base_url_deepseek})")
319
+ deepseek_api_initialized = True
320
+ # # 起動時の接続テストは必須ではないのでコメントアウト
321
+ # try:
322
+ # models = client_deepseek.models.list()
323
+ # logging.debug(f"DeepSeek接続テスト成功。利用可能なモデル (一部): {[m.id for m in models.data[:5]]}")
324
+ # except Exception as test_err:
325
+ # logging.error(f"DeepSeek APIへの接続テストに失敗しました: {test_err}")
326
+ # logging.error("APIキーまたはベースURLを確認してください。初期化は続行しますが、API呼び出しは失敗する可能性があります。")
327
+ except Exception as e:
328
+ logging.error(f"DeepSeek APIクライアントの初期化中にエラーが発生しました: {e}", exc_info=True)
329
+ else:
330
+ pass # OpenAIライブラリがない場合
331
+
332
+
333
+ def call_deepseek_via_openai(prompt, model="deepseek-chat", max_tokens=3000, temperature=0.3, max_retries=2, initial_delay=3):
334
+ """
335
+ OpenAIライブラリ経由でDeepSeek APIを呼び出す関数。リトライ機能付き。
336
+ 成功時はLLMが生成したテキスト(JSON形式を期待)を、失敗時はNoneを返す。
337
+ """
338
+ if not deepseek_api_initialized or not client_deepseek:
339
+ logging.error("DeepSeekクライアントが初期化されていないため、APIを呼び出せません。")
340
+ return None
341
+ if not OpenAI:
342
+ logging.error("OpenAIライブラリがないため、DeepSeek APIを呼び出せません。")
343
+ return None
344
+
345
+ logging.info(f"DeepSeek: API呼び出し開始 (Model: {model}, Temp: {temperature}, MaxTokens: {max_tokens})")
346
+ current_delay = initial_delay
347
+ last_exception = None
348
+
349
+ for attempt in range(max_retries + 1):
350
+ logging.debug(f"DeepSeek: API呼び出し試行 {attempt + 1}/{max_retries + 1}")
351
+ try:
352
+ start_time = time.time()
353
+ response = client_deepseek.chat.completions.create(
354
+ model=model,
355
+ messages=[
356
+ {"role": "system", "content": "You are a helpful assistant that strictly follows instructions and outputs responses in the specified format ONLY. Ensure the output is valid JSON."},
357
+ {"role": "user", "content": prompt}
358
+ ],
359
+ max_tokens=max_tokens,
360
+ temperature=temperature,
361
+ stream=False,
362
+ # response_format={"type": "json_object"}, # DeepSeekが対応していれば有効だが、未対応の場合エラーになるので注意
363
+ )
364
+ end_time = time.time()
365
+ duration = end_time - start_time
366
+ logging.debug(f"DeepSeek: API呼び出し成功 (所要時間: {duration:.2f}秒)")
367
+
368
+ if not response.choices:
369
+ logging.error("DeepSeek: API応答に choices が含まれていません。")
370
+ last_exception = ValueError("API response missing 'choices'")
371
+ # リトライしても無駄な可能性が高いので、待機時間を長くして最終試行に賭けるか、諦める
372
+ wait_time = current_delay * 2
373
+ if attempt < max_retries:
374
+ logging.info(f"DeepSeek: choicesがないためリトライします ({wait_time:.1f}秒後)...")
375
+ time.sleep(wait_time)
376
+ current_delay *= 1.5 # Backoff控えめ
377
+ continue # 次の試行へ
378
+ else:
379
+ logging.error(f"DeepSeek: 最大リトライ回数 ({max_retries + 1}回) に達しました。choicesがありません。")
380
+ return None
381
+
382
+
383
+ content = response.choices[0].message.content
384
+ finish_reason = response.choices[0].finish_reason
385
+ usage = response.usage
386
+
387
+ logging.debug(f"DeepSeek: 応答取得完了 (Finish Reason: {finish_reason}, Tokens: {usage.total_tokens if usage else 'N/A'})")
388
+ logging.debug(f"DeepSeek Generated Text (raw):\n---\n{content}\n---")
389
+
390
+ if finish_reason == 'length':
391
+ logging.warning(f"DeepSeek: max_tokens ({max_tokens}) に達したため、応答が途中で打ち切られている可能性があります。")
392
+
393
+ # --- LLM応答からJSON部分を抽出 ---
394
+ json_str = None
395
+ # 優先度1: ```json ... ``` ブロック
396
+ match_code_block = re.search(r'```(?:json)?\s*([\s\S]+?)\s*```', content, re.IGNORECASE | re.DOTALL)
397
+ if match_code_block:
398
+ json_str = match_code_block.group(1).strip()
399
+ logging.info("DeepSeek: 応答から ```json ... ``` ブロックを抽出しました。")
400
+ else:
401
+ # 優先度2: 応答全体がJSON形式か (前後の空白は許容)
402
+ json_match = re.search(r'^\s*([\[{].*[\]}])\s*$', content, re.DOTALL)
403
+ if json_match:
404
+ json_str = json_match.group(1)
405
+ logging.info("DeepSeek: 応答全体がJSON形式と判断しました。")
406
+
407
+ if json_str:
408
+ try:
409
+ # 有効なJSONかパースしてみる (パースするだけで、返すのは文字列)
410
+ json.loads(json_str)
411
+ logging.info("DeepSeek: 抽出/判断されたJSON文字列は有効です。")
412
+ # <<< 修正 >>> デバッグ用ファイルパス変更
413
+ output_filename_base = f"deepseek_output_{'summary' if 'summary' in prompt else 'quiz'}.json"
414
+ output_filename = os.path.join(TEMP_DIR, output_filename_base)
415
+ try:
416
+ with open(output_filename, "w", encoding="utf-8") as f:
417
+ f.write(json_str) # パースしたものではなく、抽出した文字列を保存
418
+ logging.debug(f"DeepSeek 応答 (JSON) を {output_filename} に保存しました。")
419
+ except IOError as e:
420
+ logging.error(f"DeepSeek 応答のファイル保存に失敗: {e}")
421
+ return json_str # 有効なJSON文字列を返す
422
+ except json.JSONDecodeError as e:
423
+ logging.warning(f"DeepSeek: 抽出/判断されたJSON文字列のパースに失敗: {e}")
424
+ last_exception = e # エラー情報を保持
425
+ # パース失敗した場合、リトライする価値はあるかもしれない
426
+ else:
427
+ # JSONが見つからなかった場合
428
+ logging.error("DeepSeek: 期待したJSON形式の応答(コードブロックまたは全体)が見つかりませんでした。")
429
+ logging.error(f"DeepSeek Raw Response: {content.strip()}")
430
+ last_exception = ValueError("No JSON found in response")
431
+ # JSONが得られなかった場合もリトライする価値はあるかもしれない
432
+
433
+ # リトライ処理 (JSONパース失敗 or JSONが見つからなかった場合)
434
+ if attempt < max_retries:
435
+ wait_time = current_delay * (random.uniform(0.8, 1.2))
436
+ logging.info(f"DeepSeek: JSON取得/パース失敗のためリトライします ({wait_time:.1f}秒後)...")
437
+ time.sleep(wait_time)
438
+ current_delay *= 2 # Exponential Backoff
439
+ continue # 次の試行へ
440
+ else:
441
+ # リトライ上限に達した場合
442
+ logging.error(f"DeepSeek: 最大リトライ回数 ({max_retries + 1}回) に達しました。有効なJSON応答を得られませんでした。")
443
+ return None # 失敗としてNoneを返す
444
+
445
+
446
+ # --- エラーハンドリングとリトライ ---
447
+ except AuthenticationError as e:
448
+ logging.error(f"DeepSeek: 認証エラー (試行 {attempt + 1}): {e}. APIキーを確認してください。")
449
+ return None # 認証エラーはリトライしない
450
+ except BadRequestError as e:
451
+ logging.error(f"DeepSeek: 不正なリクエストエラー (試行 {attempt + 1}): {e}. プロンプトやパラメータを確認してください。")
452
+ # プロンプトが長すぎる場合などもここに来る可能性
453
+ return None # リクエスト内容が悪い場合はリトライしない
454
+ except RateLimitError as e:
455
+ last_exception = e
456
+ logging.warning(f"DeepSeek: APIレート制限エラーが発生しました (試行 {attempt + 1}): {e}")
457
+ wait_time = current_delay * (random.uniform(1.0, 1.5)) # レート制限は少し長めに待つ
458
+ logging.info(f"DeepSeek: レート制限のためリトライします ({wait_time:.1f}秒後)...")
459
+ except (APITimeoutError, APIConnectionError) as e:
460
+ last_exception = e
461
+ logging.warning(f"DeepSeek: APIタイムアウト/接続エラーが発生しました (試行 {attempt + 1}): {e}")
462
+ wait_time = current_delay * (random.uniform(0.8, 1.2))
463
+ logging.info(f"DeepSeek: タイムアウト/接続エラーのためリトライします ({wait_time:.1f}秒後)...")
464
+ except APIError as e: # その他のAPIエラー (例: 5xxサーバーエラー)
465
+ last_exception = e
466
+ logging.warning(f"DeepSeek: APIエラーが発生しました (試行 {attempt + 1}): HTTP Status={getattr(e, 'status_code', 'N/A')}, Type={getattr(e, 'type', 'N/A')}, Message={getattr(e, 'message', str(e))}")
467
+ status_code = getattr(e, 'status_code', None)
468
+ if status_code and 500 <= status_code < 600:
469
+ wait_time = current_delay * (random.uniform(0.8, 1.2))
470
+ logging.info(f"DeepSeek: サーバーエラー({status_code})のためリトライします ({wait_time:.1f}秒後)...")
471
+ else:
472
+ logging.error(f"DeepSeek: リトライ不可能なAPIエラー({status_code})です。")
473
+ return None
474
+ except Exception as e:
475
+ last_exception = e
476
+ logging.error(f"DeepSeek: API呼び出し中に予期せぬPythonエラーが発生しました (試行 {attempt + 1}): {type(e).__name__}: {e}", exc_info=True)
477
+ wait_time = current_delay * (random.uniform(0.8, 1.2))
478
+ logging.info(f"DeepSeek: 予期せぬエラーのためリトライします ({wait_time:.1f}秒後)...")
479
+
480
+ # リトライ待機 (最終試行でなければ)
481
+ if attempt < max_retries:
482
+ time.sleep(wait_time)
483
+ current_delay *= 2 # 次の遅延時間を増やす (Exponential Backoff)
484
+ else:
485
+ logging.error(f"DeepSeek: 最大リトライ回数 ({max_retries + 1}回) に達しました。API呼び出しを諦めます。")
486
+ if last_exception:
487
+ logging.error(f"DeepSeek: 最後に発生したエラー: {last_exception}")
488
+ return None
489
+
490
+ return None # この行には到達しないはず
491
+
492
+
493
+ def validate_summary_item(item, index):
494
+ """要約リストの単一要素を検証するヘルパー関数"""
495
+ if not isinstance(item, dict):
496
+ raise ValueError(f"要約要素 {index + 1} が辞書形式ではありません。")
497
+ required_keys = ["id", "type", "text"]
498
+ missing_keys = [k for k in required_keys if k not in item]
499
+ if missing_keys:
500
+ raise ValueError(f"要約要素 {index + 1} に必須キー ({', '.join(missing_keys)}) が不足しています。")
501
+ if not isinstance(item.get("id"), str) or not item.get("id"):
502
+ raise ValueError(f"要約要素 {index + 1} の 'id' が空でない文字列ではありません。")
503
+ if item.get("type") != "summary":
504
+ raise ValueError(f"要約要素 {index + 1} の 'type' が 'summary' ではありません。 Actual: '{item.get('type')}'")
505
+ if not isinstance(item.get("text"), str): # textは空文字列を許容
506
+ raise ValueError(f"要約要素 {index + 1} の 'text' が文字列ではありません。")
507
+ # 追加: textが過度に短い、または長すぎる場合のチェックなど? (任意)
508
+ return item
509
+
510
+ def generate_summary(transcript_or_json_str):
511
+ """
512
+ 入力テキストまたはJSON文字列から、要約スライドのリストを生成する。
513
+ Geminiが直接有効なJSONを返した場合はそれを使い、そうでなければDeepSeekに依頼する。
514
+ 成功時は要約リスト(Pythonオブジェクト)を、失敗時はNoneを返す。
515
+ """
516
+ logging.info("バックエンド: 要約生成処理 開始")
517
+ validated_list = None
518
+ transcript_text = transcript_or_json_str # DeepSeek用入力のデフォルト
519
+
520
+ # 1. 入力がGeminiからの有効なJSONリストか試す
521
+ if isinstance(transcript_or_json_str, str) and transcript_or_json_str.strip().startswith('['):
522
+ try:
523
+ parsed_data = json.loads(transcript_or_json_str)
524
+ if isinstance(parsed_data, list):
525
+ logging.debug("バックエンド: 入力はリスト形式のJSONです。Geminiからの応答として検証します。")
526
+ current_validated_list = []
527
+ if not parsed_data:
528
+ # 空リストは許容しないことにする
529
+ logging.warning("Gemini応答: JSONリストが空です。")
530
+ # raise ValueError("Gemini応答: JSONリストが空です。") # エラーにするか、DeepSeekに回すか
531
+ # DeepSeekに回すことにする
532
+ else:
533
+ for i, item in enumerate(parsed_data):
534
+ current_validated_list.append(validate_summary_item(item, i)) # ヘルパー関数で検証
535
+ logging.info("バックエンド: Geminiからの直接生成された要約JSONを検証し、使用します。")
536
+ validated_list = current_validated_list # 検証成功
537
+ else:
538
+ logging.warning("バックエンド: Gemini応答はJSONでしたがリスト形式ではありません。DeepSeekに要約を依頼します。")
539
+ # この場合、transcript_text は元の文字列のまま
540
+ except (json.JSONDecodeError, TypeError) as e:
541
+ # JSON文字列に見えたがパース失敗
542
+ logging.info(f"バックエンド: 入力は有効な要約JSONリストではありません ({type(e).__name__}: {e})。DeepSeekに要約を依頼します。")
543
+ # transcript_text は元の文字列のまま
544
+ except ValueError as ve:
545
+ # 形式はリストだが、中身の検証でエラー (validate_summary_item)
546
+ logging.warning(f"バックエンド: Gemini応答JSONリストの検証に失敗: {ve}。DeepSeekに要約を依頼します。")
547
+ # transcript_text は元の文字列のまま
548
+ else:
549
+ # 最初からJSON文字列ではなかった場合
550
+ logging.info("バックエンド: 入力がJSONリスト形式ではないため、DeepSeekに要約を依頼します。")
551
+ # transcript_text は元の文字列のまま
552
+
553
+ # 2. Geminiの結果が使えなかった場合、DeepSeekに要約生成を依頼する
554
+ if validated_list is None: # Geminiの結果が使えなかった場合のみ実行
555
+ if not deepseek_api_initialized:
556
+ logging.error("バックエンド: 要約生成失敗 (DeepSeekクライアント未初期化)")
557
+ return None
558
+ if not transcript_text: # DeepSeekへの入力が空でないか確認
559
+ logging.error("バックエンド: 要約生成失敗 (DeepSeekへの入力テキストが空です)")
560
+ return None
561
+
562
+
563
+ logging.info("バックエンド: DeepSeek APIによる要約生成 開始")
564
+ # <<< 修正 >>> DeepSeekモデル名を環境変数から取得(任意)
565
+ deepseek_model_name = os.getenv('DEEPSEEK_MODEL', 'deepseek-chat')
566
+ logging.info(f"DeepSeek: 使用モデル - {deepseek_model_name}")
567
+ prompt = f"""
568
+ 以下のテキストを分析し、内容の理解を助けるために、キーポイントを複数のスライド形式で要約してください。テキストが非常に長い場合は、主要な部分を網羅するようにしてください。
569
+
570
+ **厳格な出力形式の指示:**
571
+ 結果は、**必ずJSONリスト `[]` のみ**としてくだ���い。JSONリストの前後に、**一切のテキスト(導入文、説明、補足、マークダウンの```json ... ```など)を含めないでください**。出力はJSONリストそのものでなければなりません。
572
+ リストの各要素は、**必ず**以下のキーを持つJSONオブジェクト `{{}}` でなければなりません:
573
+ - `"id"`: スライド番号を表す文字列。例のように `"s1"`, `"s2"`, `"s3"` と連番にしてください。
574
+ - `"type"`: 文字列であり、**必ず** `"summary"` という値にしてください。
575
+ - `"text"`: そのスライドの要約内容を含む文字列。簡潔かつ分かりやすく記述してください。テキスト内で改行する場合は `\\n` を使用してください。JSONとして有効なように、テキスト内の特殊文字(引用符など)は適切にエスケープしてください。
576
+
577
+ **必須のJSON出力形式の例(この例自体を出力に含めないでください):**
578
+ [
579
+ {{"id": "s1", "type": "summary", "text": "First summary point for slide 1."}},
580
+ {{"id": "s2", "type": "summary", "text": "Second summary point.\\nThis one has a newline."}}
581
+ ]
582
+
583
+ **入力テキスト:**
584
+ ---
585
+ {transcript_text}
586
+ ---
587
+
588
+ 上記の指示に厳密に従い、要約スライドの**JSONリストのみ**を出力してください。出力は有効なJSONでなければなりません。他のテキストは一切含めないでください。
589
+ """
590
+ # DeepSeekへのリクエスト、長文に対応できるようmax_tokensを調整
591
+ response_str = call_deepseek_via_openai(prompt, model=deepseek_model_name, temperature=0.5, max_tokens=3000)
592
+
593
+ if response_str:
594
+ try:
595
+ summary_list_deepseek = json.loads(response_str)
596
+ if not isinstance(summary_list_deepseek, list):
597
+ raise ValueError("DeepSeek応答がリスト形式ではありません。")
598
+ if not summary_list_deepseek:
599
+ raise ValueError("DeepSeek応答リストが空です。")
600
+
601
+ validated_list_deepseek = []
602
+ for i, item in enumerate(summary_list_deepseek):
603
+ validated_list_deepseek.append(validate_summary_item(item, i)) # ヘルパー関数で検証
604
+
605
+ logging.info("バックエンド: DeepSeekによる要約生成完了 (JSONパース・検証成功)")
606
+ return validated_list_deepseek # 検証済みのPythonリストを返す
607
+ except (json.JSONDecodeError, ValueError) as e:
608
+ logging.error(f"バックエンド: DeepSeek要約生成失敗 (JSONパースまたは検証エラー: {e})")
609
+ logging.error(f"DeepSeek応答 (生文字列):\n---\n{response_str}\n---")
610
+ return None
611
+ else:
612
+ logging.error("バックエンド: DeepSeek要約生成失敗 (API呼び出し失敗または有効なJSON応答なし)")
613
+ return None
614
+ else:
615
+ # Geminiの結果が使えた場合
616
+ return validated_list
617
+
618
+
619
+ def validate_quiz_item(item, index):
620
+ """クイズリストの単一要素を検証するヘルパー関数"""
621
+ if not isinstance(item, dict):
622
+ raise ValueError(f"クイズ要素 {index + 1} が辞書形式ではありません。")
623
+ required_keys = ["id", "type", "text", "options", "answer"]
624
+ missing_keys = [k for k in required_keys if k not in item]
625
+ if missing_keys:
626
+ raise ValueError(f"クイズ要素 {index + 1} に必須キー ({', '.join(missing_keys)}) が不足しています。")
627
+ if not isinstance(item.get("id"), str) or not item.get("id"):
628
+ raise ValueError(f"クイズ要素 {index + 1} の 'id' が空でない文字列ではありません。")
629
+ if item.get("type") != "question":
630
+ raise ValueError(f"クイズ要素 {index + 1} の 'type' が 'question' ではありません。 Actual: '{item.get('type')}'")
631
+ if not isinstance(item.get("text"), str) or not item.get("text"):
632
+ raise ValueError(f"クイズ要素 {index + 1} の 'text' (質問文) が空でない文字列ではありません。")
633
+ options = item.get("options")
634
+ # オプションの数をチェック (例: 3個または4個を期待する場合)
635
+ expected_options_count = 4 # 例として4択を期待
636
+ if not isinstance(options, list) or len(options) != expected_options_count:
637
+ raise ValueError(f"クイズ要素 {index + 1} の 'options' が正確に{expected_options_count}個の要素を持つリストではありません (現在: {len(options) if isinstance(options, list) else '非リスト'})。")
638
+ if not all(isinstance(opt, str) and opt for opt in options):
639
+ raise ValueError(f"クイズ要素 {index + 1} の 'options' の要素がすべて空でない文字列ではありません。")
640
+ answer = item.get("answer")
641
+ if not isinstance(answer, str) or not answer:
642
+ raise ValueError(f"クイズ要素 {index + 1} の 'answer' が空でない文字列ではありません。")
643
+ if answer not in options:
644
+ # 答えが選択肢内にないのは致命的
645
+ raise ValueError(f"クイズ要素 {index + 1} の 'answer' ('{answer}') が 'options' {options} 内に見つかりません。")
646
+ return item
647
+
648
+
649
+ def generate_quiz(transcript_or_json_str):
650
+ """
651
+ 入力テキストまたはJSON文字列から、クイズのリストを生成する (DeepSeek APIを使用)。
652
+ 成功時はクイズリスト(Pythonオブジェクト)を、失敗時はNoneを返す。
653
+ """
654
+ logging.info("バックエンド: クイズ生成処理 開始 (DeepSeek API)")
655
+
656
+ transcript_text = transcript_or_json_str
657
+ # 入力がJSON文字列かどうかをチェック(ログ出力用)
658
+ is_json_input = False
659
+ if isinstance(transcript_text, str):
660
+ try:
661
+ json.loads(transcript_text)
662
+ if transcript_text.strip().startswith(('[', '{')):
663
+ is_json_input = True
664
+ except (json.JSONDecodeError, TypeError):
665
+ pass
666
+ if is_json_input:
667
+ logging.warning("バックエンド: クイズ生成への入力がJSON形式でした。そのままテキストとして扱います。")
668
+
669
+ if not deepseek_api_initialized:
670
+ logging.error("バックエンド: クイズ生成失敗 (DeepSeekクライアント未初期化)")
671
+ return None
672
+ if not transcript_text:
673
+ logging.error("バックエンド: クイズ生成失敗 (入力テキストが空です)")
674
+ return None
675
+
676
+ # --- クイズ設定 ---
677
+ num_questions = 5 # 生成する問題数
678
+ num_options = 4 # 各問題の選択肢の数
679
+
680
+ logging.info(f"バックエンド: DeepSeek APIによるクイズ生成 開始 ({num_questions}問, {num_options}択)")
681
+ # <<< 修正 >>> DeepSeekモデル名を環境変数から取得(任意)
682
+ deepseek_model_name = os.getenv('DEEPSEEK_MODEL', 'deepseek-chat')
683
+ logging.info(f"DeepSeek: 使用モデル - {deepseek_model_name}")
684
+ prompt = f"""
685
+ 以下のテキストを分析し、内容の理解度をテストするために、**正確に{num_questions}個**の多肢選択式クイズ問題を生成してください。各問題には、それぞれ**正確に{num_options}個**の異なる選択肢が必要です。
686
+
687
+ **厳格な出力形式の指示:**
688
+ 結果は、**必ずJSONリスト `[]` のみ**としてください。JSONリストの前後に、**一切のテキスト(導入文、説明、補足、マークダウンの```json ... ```など)を含めないでください**。出力はJSONリストそのものでなければなりません。
689
+ リストの各要素は、**必ず**以下のキーを持つJSONオブジェクト `{{}}` でなければなりません:
690
+ - `"id"`: 質問番号を表す文字列。 `"q1"`, `"q2"`, ..., `"q{num_questions}"` と連番にしてください。
691
+ - `"type"`: 文字列であり、**必ず** `"question"` という値にしてください。
692
+ - `"text"`: 質問文そのものを含む文字列。具体的で明確な質問にしてください。
693
+ - `"options"`: 文字列のリスト `[]` であり、**正確に{num_options}個**の解答選択肢を含めてください。選択肢は互いに区別可能で、正解以外の選択肢(不正解の選択肢)ももっともらしいものにしてください。
694
+ - `"answer"`: 正しい答えを指定する文字列。この文字列は、**必ず**その質問の `"options"` リストに含まれる文字列のいずれかでなければなりません。
695
+
696
+ **必須のJSON出力形式の例({num_questions}=3, {num_options}=4の場合 - この例自体を出力に含めないでください):**
697
+ [
698
+ {{"id": "q1", "type": "question", "text": "What was the primary focus of the discussion?", "options": ["Topic A", "Topic B", "Topic C", "Topic D"], "answer": "Topic B"}},
699
+ {{"id": "q2", "type": "question", "text": "Which specific example was mentioned?", "options": ["Example X", "Example Y", "Example Z", "Example W"], "answer": "Example Y"}},
700
+ {{"id": "q3", "type": "question", "text": "What is the recommended next action?", "options": ["Action 1", "Action 2", "Action 3", "Action 4"], "answer": "Action 1"}}
701
+ ]
702
+
703
+ **入力テキスト:**
704
+ ---
705
+ {transcript_text}
706
+ ---
707
+
708
+ 上記の指示に厳密に従ってください。**正確に{num_questions}個**のクイズ問題を生成し、**JSONリストのみ**を出力してください。他のテキストは一切含めないでください。出力は有効なJSONでなければなりません。
709
+ """
710
+ # クイズ生成は比較的短い応答で済むことが多いが、入力テキストによっては長くかかる可能性
711
+ # max_tokens は num_questions や num_options に応じて調整
712
+ max_tokens_quiz = num_questions * (150 + num_options * 50) # 大まかな目安 (質問文+選択肢)
713
+ response_str = call_deepseek_via_openai(prompt, model=deepseek_model_name, temperature=0.4, max_tokens=max_tokens_quiz)
714
+
715
+ if response_str:
716
+ try:
717
+ quiz_list = json.loads(response_str)
718
+ if not isinstance(quiz_list, list):
719
+ raise ValueError("DeepSeek応答がリスト形式ではありません。")
720
+ # 生成された問題数をチェック
721
+ if len(quiz_list) != num_questions:
722
+ logging.warning(f"DeepSeekが生成したクイズ数が指定と異なります (期待: {num_questions}, 実際: {len(quiz_list)})")
723
+ # ここでエラーにするか、そのまま使うかは要件次第
724
+ # if len(quiz_list) == 0: raise ValueError("DeepSeek応答クイズリストが空です。") # 空の場合はエラー
725
+ if not quiz_list:
726
+ raise ValueError("DeepSeek応答クイズリストが空です。")
727
+
728
+ validated_list = []
729
+ for i, item in enumerate(quiz_list):
730
+ # IDを q1, q2... に強制的に振り直す (LLMが従わない場合があるため)
731
+ item['id'] = f'q{i+1}'
732
+ validated_list.append(validate_quiz_item(item, i)) # ヘルパー関数で検証
733
+
734
+ logging.info(f"バックエンド: DeepSeekによるクイズ生成完了 ({len(validated_list)}問 JSONパース・検証成功)")
735
+ return validated_list
736
+ except (json.JSONDecodeError, ValueError) as e:
737
+ logging.error(f"バックエンド: DeepSeekクイズ生成失敗 (JSONパースまたは検証エラー: {e})")
738
+ logging.error(f"DeepSeek応答 (生文字列):\n---\n{response_str}\n---")
739
+ return None
740
+ else:
741
+ logging.error("バックエンド: DeepSeekクイズ生成失敗 (API呼び出し失敗または有効なJSON応答なし)")
742
+ return None
743
+
744
+
745
+ # --- Flask ルーティング (HTMLページ表示) ---
746
+
747
+ @app.route('/')
748
+ @app.route('/input')
749
+ def input_page():
750
+ """入力画面を表示"""
751
+ logging.debug("Routing: /input ページ表示")
752
+ return render_template('input.html')
753
+
754
+ @app.route('/history')
755
+ def history_page():
756
+ """履歴画面を表示"""
757
+ logging.debug("Routing: /history ページ表示")
758
+ history_items = []
759
+ error_message = None
760
+
761
+ # ↓↓↓ チェックを修正: 未設定または空文字列の場合のみエラーとする ↓↓↓
762
+ if not GAS_WEB_APP_URL:
763
+ logging.error("/history: GAS_WEB_APP_URLが設定されていません。") # エラーメッセージも少し具体的に
764
+ error_message = "データベース接続設定が不完全なため、履歴を取得できません。(URL未設定)"
765
+ else:
766
+ # GASへのリクエスト処理
767
+ try:
768
+ logging.debug(f"/history: GASへの履歴取得リクエスト送信 - URL: {GAS_WEB_APP_URL}")
769
+ # GETリクエスト (idパラメータなし) を送る
770
+ gas_response = requests.get(GAS_WEB_APP_URL, timeout=60)
771
+ gas_response.raise_for_status() # HTTPエラーチェック
772
+
773
+ gas_result = gas_response.json()
774
+ logging.debug(f"/history: GASからの応答受信: {gas_result}")
775
+
776
+ # GAS応答を検証 (変更なし)
777
+ if gas_result.get("success") and isinstance(gas_result.get("data"), list):
778
+ history_items = gas_result["data"]
779
+ logging.info(f"/history: GASからの履歴取得 成功 ({len(history_items)}件)")
780
+ elif not gas_result.get("success"):
781
+ gas_error_msg = gas_result.get('message', 'GASからの履歴取得中に不明なエラーが発生しました。')
782
+ logging.error(f"/history: GASからの履歴取得失敗 (GAS側エラー): {gas_error_msg}")
783
+ error_message = f"データベースエラー: {gas_error_msg}"
784
+ else:
785
+ logging.error(f"/history: GASからの応答形式が予期しないものです (dataがリストでない等)。")
786
+ error_message = "データベースからの応答形式が不正です。"
787
+
788
+ except requests.exceptions.Timeout:
789
+ logging.error("/history: GASへの接続がタイムアウトしました。")
790
+ error_message = "データベースへの接続がタイムアウトしました。"
791
+ except requests.exceptions.HTTPError as http_err:
792
+ status_code = http_err.response.status_code if http_err.response else "N/A"
793
+ logging.error(f"/history: GASへの接続でHTTPエラーが発生: Status={status_code}, Error={http_err}")
794
+ error_message = f"データベース接続エラー (HTTP {status_code})。"
795
+ except requests.exceptions.RequestException as req_err:
796
+ logging.error(f"/history: GASへの接続中にネットワークエラー等が発生: {req_err}")
797
+ error_message = f"データベース接続エラー: {req_err}"
798
+ except json.JSONDecodeError as json_err:
799
+ raw_gas_response = gas_response.text if 'gas_response' in locals() else "N/A"
800
+ logging.error(f"/history: GASからの応答JSONのパースに失敗: {json_err}")
801
+ logging.error(f"GAS Raw Response: {raw_gas_response[:500]}...")
802
+ error_message = "データベースからの応答形式が不正です。"
803
+ except Exception as e:
804
+ logging.error(f"/history: 履歴取得中に予期せぬエラーが発生: {e}", exc_info=True)
805
+ error_message = "サーバー内部エラーが発生しました。"
806
+
807
+ # 取得したデータまたはエラーメッセージをテンプレートに渡す (変更なし)
808
+ return render_template('history.html', history_items=history_items, error_message=error_message)
809
+
810
+ @app.route('/learning')
811
+ def learning_page():
812
+ """学習画面を表示"""
813
+ content_id = request.args.get('id')
814
+ logging.debug(f"Routing: /learning ページ表示リクエスト (Content ID: {content_id})")
815
+ if not content_id:
816
+ logging.warning("学習画面リクエストで content_id が指定されていません。")
817
+ return render_template('learning.html', error_message="表示するコンテンツIDが指定されていません。", content_id=None, title="エラー")
818
+ # タイトルはJSがAPIから取得して設定する想定
819
+ return render_template('learning.html', content_id=content_id, title="学習コンテンツ読み込み中...")
820
+
821
+ @app.route('/settings')
822
+ def settings_page():
823
+ """設定画面を表示"""
824
+ logging.debug("Routing: /settings ページ表示")
825
+ return render_template('settings.html')
826
+
827
+ # --- Flask APIエンドポイント ---
828
+
829
+ @app.route('/api/generate', methods=['POST'])
830
+ def generate_content():
831
+ """
832
+ YouTube URLを受け取り、音声抽出、文字起こし、要約、クイズ生成を行い、
833
+ 結果をGASに保存し、クライアントにも返すAPI。
834
+ """
835
+ start_time_generate = time.time()
836
+ logging.info("API /api/generate: リクエスト受信")
837
+
838
+ if not request.is_json:
839
+ logging.warning("API /api/generate: リクエスト形式が不正 (非JSON)")
840
+ return jsonify({"success": False, "message": "リクエストはJSON形式である必要があります。"}), 400
841
+
842
+ data = request.get_json()
843
+ youtube_url = data.get('url')
844
+
845
+ if not youtube_url:
846
+ logging.warning("API /api/generate: URLが指定されていません")
847
+ return jsonify({"success": False, "message": "YouTubeのURLが指定されていません。"}), 400
848
+
849
+ # URL形式チェック (yt-dlpに任せるので緩め)
850
+ if not isinstance(youtube_url, str) or 'youtube.com' not in youtube_url and 'youtu.be' not in youtube_url:
851
+ logging.warning(f"API /api/generate: YouTube URLとして疑わしい形式です: {youtube_url}")
852
+ # ここでエラーにするか、yt-dlpのエラーに任せるか選択
853
+ # return jsonify({"success": False, "message": "有効なYouTubeのURLを指定してください。"}), 400
854
+
855
+ logging.info(f"API /api/generate: URL='{youtube_url}' で処理開始")
856
+
857
+ audio_path = None
858
+ generated_data = None
859
+ content_id = None
860
+ video_info = None # 動画情報を格納
861
+
862
+ try:
863
+ # --- 1. 音声抽出 & 動画情報取得 ---
864
+ step_start_time = time.time()
865
+ logging.info("API /api/generate: (1/5) 音声抽出 & 動画情報取得 開始")
866
+ # download_and_extract_audio は (audio_path, video_info) を返すように修正
867
+ audio_path, video_info = download_and_extract_audio(youtube_url)
868
+ if not audio_path:
869
+ # video_info にエラーメッセージなどが入っている可能性もあるが、audio_pathがなければ失敗
870
+ raise ValueError("音声ファイルの抽出に失敗しました。URLが正しいか、動画が利用可能か確認してください。")
871
+ logging.info(f"API /api/generate: (1/5) 音声抽出完了 - Path: {audio_path} (所要時間: {time.time() - step_start_time:.2f}秒)")
872
+ if not video_info: # audio_pathがあってもvideo_infoがない場合 (通常はありえないはず)
873
+ video_info = {'title': '不明な動画', 'thumbnail': None}
874
+ logging.warning("API /api/generate: 動画情報の取得に失敗しましたが、音声抽出は成功しました。")
875
+
876
+
877
+ # --- 2. 文字起こし・初期要約 (Gemini) ---
878
+ step_start_time = time.time()
879
+ logging.info("API /api/generate: (2/5) 文字起こし・初期要約 (Gemini) 開始")
880
+ transcript_or_summary_json = transcribe_audio(audio_path)
881
+ if transcript_or_summary_json is None:
882
+ raise ValueError("Geminiによる文字起こし・初期要約処理に失敗しました。APIキーやクォータを確認してください。")
883
+ if not transcript_or_summary_json:
884
+ logging.warning("API /api/generate: Geminiからの応答が空でした。")
885
+ raise ValueError("Geminiによる文字起こし・初期要約結果が空です。音声が無音でないか確認してください。")
886
+ logging.info(f"API /api/generate: (2/5) 文字起こし・初期要約 (Gemini) 完了 (��要時間: {time.time() - step_start_time:.2f}秒)")
887
+
888
+ # --- 3. 最終的な要約リスト生成 (Gemini結果 or DeepSeek) ---
889
+ step_start_time = time.time()
890
+ logging.info("API /api/generate: (3/5) 最終要約リスト生成 開始")
891
+ summary_items = generate_summary(transcript_or_summary_json)
892
+ if not summary_items:
893
+ # generate_summary内でエラーログが出力されているはず
894
+ raise ValueError("要約リストの生成に失敗しました。モデルの応答形式を確認してください。")
895
+ logging.info(f"API /api/generate: (3/5) 最終要約リスト生成 完了 ({len(summary_items)}項目) (所要時間: {time.time() - step_start_time:.2f}秒)")
896
+
897
+ # --- 4. クイズ生成 (DeepSeek) ---
898
+ step_start_time = time.time()
899
+ logging.info("API /api/generate: (4/5) クイズ生成 開始")
900
+ # クイズ生成にはGeminiの応答(JSON文字列またはテキスト)を使う
901
+ question_items = generate_quiz(transcript_or_summary_json)
902
+ if not question_items:
903
+ raise ValueError("クイズの生成に失敗しました。モデルの応答形式を確認してください。")
904
+ logging.info(f"API /api/generate: (4/5) クイズ生成 完了 ({len(question_items)}項目) (所要時間: {time.time() - step_start_time:.2f}秒)")
905
+
906
+ # --- 成功データの準備 ---
907
+ content_id = f"cont_{int(time.time())}_{random.randint(1000, 9999)}"
908
+ # yt-dlpから取得した情報を使用
909
+ video_title = video_info.get('title', f"生成コンテンツ {content_id[-4:]}")
910
+ # サムネイルURLもyt-dlpから取得、なければデフォルト
911
+ thumbnail_url = video_info.get('thumbnail')
912
+ if not thumbnail_url and video_info.get('id'):
913
+ # yt-dlpで取得できなかった場合、標準的なURLを試す
914
+ thumbnail_url = f"https://i.ytimg.com/vi/{video_info['id']}/mqdefault.jpg"
915
+
916
+ generated_data = {
917
+ "id": content_id,
918
+ "title": video_title,
919
+ "thumbnail": thumbnail_url or '', # Noneではなく空文字を渡す
920
+ "summary": summary_items, # Pythonリスト
921
+ "questions": question_items # Pythonリスト
922
+ }
923
+ logging.debug(f"API /api/generate: 生成データ準備完了 (ID: {content_id})")
924
+
925
+ # --- 5. GASにデータを保存 ---
926
+ step_start_time = time.time()
927
+ logging.info("API /api/generate: (5/5) GASへのデータ保存 開始")
928
+ # GAS URLのチェックを強化
929
+ if not GAS_WEB_APP_URL or 'YOUR_PLACEHOLDER_GAS_URL' in GAS_WEB_APP_URL or not GAS_WEB_APP_URL.startswith('https://script.google.com/macros/s/'):
930
+ logging.warning("API /api/generate: GAS_WEB_APP_URLが無効または設定されていません。データは保存されません。")
931
+ # 保存失敗でも処理は続行し、生成データを返す
932
+ else:
933
+ try:
934
+ headers = {'Content-Type': 'application/json'}
935
+ # GASのdoPostに送信するデータ (Python dict)
936
+ # GAS側で summary と questions を stringify するので、ここではリストのまま送る
937
+ payload_to_gas = generated_data.copy() # 元のデータを変更しないようにコピー
938
+
939
+ gas_response = requests.post(
940
+ GAS_WEB_APP_URL,
941
+ headers=headers,
942
+ data=json.dumps(payload_to_gas), # Python dictをJSON文字列に変換
943
+ timeout=60 # GASのタイムアウトを少し長めに設定 (60秒)
944
+ )
945
+ gas_response.raise_for_status()
946
+
947
+ gas_result = gas_response.json()
948
+ if gas_result.get("success"):
949
+ returned_content_id = gas_result.get("content_id")
950
+ logging.info(f"API /api/generate: (5/5) GASへのデータ保存 成功 (GASが返したID: {returned_content_id}) (所要時間: {time.time() - step_start_time:.2f}秒)")
951
+ # Flask側で生成したIDとGASが返したIDが一致するか確認 (任意)
952
+ if returned_content_id != content_id:
953
+ logging.warning(f"GAS保存後のContent ID不一致: Flask側={content_id}, GAS側={returned_content_id}")
954
+ else:
955
+ gas_error_msg = gas_result.get('message', 'GAS側でエラーが発生しました。')
956
+ logging.error(f"API /api/generate: GASへのデータ保存失敗 (GAS応答): {gas_error_msg}")
957
+ # 警告に留める
958
+
959
+ except requests.exceptions.Timeout:
960
+ logging.error("API /api/generate: GASへの接続がタイムアウトしました。データは保存されませんでした。")
961
+ except requests.exceptions.RequestException as req_err:
962
+ # HTTPエラーの詳細(ステータスコード、応答内容)をログ���出力
963
+ status_code = req_err.response.status_code if req_err.response else "N/A"
964
+ response_text = req_err.response.text if req_err.response else "N/A"
965
+ logging.error(f"API /api/generate: GASへのデータ保存中にネットワーク/HTTPエラーが発生: Status={status_code}, Error={req_err}. Response: {response_text[:500]}...") # 応答が長い場合に切り詰める
966
+ except json.JSONDecodeError as json_err:
967
+ logging.error(f"API /api/generate: GASからの応答JSONのパースに失敗: {json_err}")
968
+ # GASからの生応答を出力 (gas_response 変数が存在する場合)
969
+ raw_gas_response = "N/A"
970
+ if 'gas_response' in locals() and hasattr(gas_response, 'text'):
971
+ raw_gas_response = gas_response.text
972
+ logging.error(f"GAS Raw Response: {raw_gas_response[:500]}...")
973
+ # GAS保存失敗は警告として処理を続行
974
+
975
+ # --- 最終的な成功応答 ---
976
+ total_duration = time.time() - start_time_generate
977
+ logging.info(f"API /api/generate: 全処理成功 (Total Time: {total_duration:.2f}秒) - Content ID: {content_id}")
978
+ # 生成したデータを返す (GAS保存の成否に関わらず)
979
+ return jsonify({"success": True, "data": generated_data}), 200
980
+
981
+ except ValueError as ve: # バックエンド処理中の予期されたエラー
982
+ total_duration = time.time() - start_time_generate
983
+ logging.error(f"API /api/generate: 処理中にエラーが発生しました (ValueError): {ve} (Total Time: {total_duration:.2f}秒)")
984
+ # ユーザーフレンドリーなメッセージを返す
985
+ return jsonify({"success": False, "message": f"コンテンツ生成エラー: {str(ve)}"}), 400 # Bad Request
986
+ except Exception as e: # その他の予期せぬエラー
987
+ total_duration = time.time() - start_time_generate
988
+ logging.error(f"API /api/generate: 処理中に予期せぬエラーが発生しました: {type(e).__name__}: {e} (Total Time: {total_duration:.2f}秒)", exc_info=True)
989
+ return jsonify({"success": False, "message": "サーバー内部で予期せぬエラーが発生しました。管理者にご連絡ください。"}), 500 # Internal Server Error
990
+
991
+ finally:
992
+ # --- 一時音声ファイルの削除 ---
993
+ # <<< 修正 >>> audio_pathが /tmp 配下のパスであることを想定
994
+ if audio_path and os.path.exists(audio_path):
995
+ try:
996
+ os.remove(audio_path)
997
+ logging.info(f"一時音声ファイル {audio_path} を削除しました。")
998
+ except OSError as rm_err:
999
+ logging.error(f"一時音声ファイル {audio_path} の削除に失敗しました: {rm_err}")
1000
+
1001
+
1002
+ @app.route('/api/learning/<content_id>', methods=['GET'])
1003
+ def get_learning_content(content_id):
1004
+ """
1005
+ 指定されたIDの学習コンテンツ(要約とクイズ)をGASから取得するAPI。
1006
+ GASのdoGetは、指定IDのデータを {success: true, data: {id:.., title:.., ..., items: [...], totalItems: ...}} の形で返す想定。
1007
+ """
1008
+ start_time_learning = time.time()
1009
+ logging.info(f"API /api/learning/{content_id}: リクエスト受信")
1010
+
1011
+ if not content_id:
1012
+ logging.warning(f"API /api/learning: content_id が指定されていません。")
1013
+ return jsonify({"success": False, "message": "コンテンツIDが指定されていません。"}), 400
1014
+
1015
+ # GAS URLのチェック
1016
+ if not GAS_WEB_APP_URL or 'YOUR_PLACEHOLDER_GAS_URL' in GAS_WEB_APP_URL or not GAS_WEB_APP_URL.startswith('https://script.google.com/macros/s/'):
1017
+ logging.error(f"API /api/learning/{content_id}: GAS_WEB_APP_URLが無効または設定されていません。")
1018
+ return jsonify({"success": False, "message": "データベース接続設定が不完全です。"}), 500
1019
+
1020
+ try:
1021
+ params = {'id': content_id}
1022
+ logging.debug(f"API /api/learning/{content_id}: GASへのデータ取得リクエスト送信 - URL: {GAS_WEB_APP_URL} Params: {params}")
1023
+
1024
+ gas_response = requests.get(GAS_WEB_APP_URL, params=params, timeout=60) # タイムアウト60秒
1025
+ gas_response.raise_for_status()
1026
+
1027
+ gas_result = gas_response.json()
1028
+ logging.debug(f"API /api/learning/{content_id}: GASからの応答受信 (raw): {gas_result}")
1029
+
1030
+ # GAS側の応答形式を検証 (success と data が存在するか)
1031
+ if "success" in gas_result and "data" in gas_result:
1032
+ if gas_result["success"] and gas_result["data"] is not None:
1033
+ # GASから受け取った 'data' フィールドの中身をそのままクライアントに返す
1034
+ response_data = gas_result["data"]
1035
+ # 'items' が存在し、リストであることを念のため確認
1036
+ if isinstance(response_data.get("items"), list):
1037
+ duration = time.time() - start_time_learning
1038
+ logging.info(f"API /api/learning/{content_id}: GASからのデータ取得 成功 ({response_data.get('totalItems', '?')}項目) (Total Time: {duration:.2f}秒)")
1039
+ return jsonify({"success": True, "data": response_data}), 200
1040
+ else:
1041
+ logging.error(f"API /api/learning/{content_id}: GAS応答の 'data.items' がリスト形式ではありません。")
1042
+ return jsonify({"success": False, "message": "データベースからの応答形式が不正です (items)。"}), 500
1043
+ elif not gas_result["success"]:
1044
+ # GAS側で success: false が返ってきた場合
1045
+ gas_error_msg = gas_result.get('message', 'GASからのデータ取得中に不明なエラーが発生しました。')
1046
+ if "not found" in gas_error_msg.lower():
1047
+ logging.warning(f"API /api/learning/{content_id}: コンテンツが見つかりませんでした (GAS応答)")
1048
+ return jsonify({"success": False, "message": f"指定されたコンテンツID '{content_id}' が見つかりません。"}), 404 # Not Found
1049
+ else:
1050
+ logging.error(f"API /api/learning/{content_id}: GASからのデータ取得失敗 (GAS側エラー): {gas_error_msg}")
1051
+ return jsonify({"success": False, "message": f"データベースエラー: {gas_error_msg}"}), 500 # Internal Server Error (or Bad Gateway 502)
1052
+ else: # success: true だけど data が null の場合 (GASがそのように返す場合)
1053
+ logging.warning(f"API /api/learning/{content_id}: GASは成功と応答しましたが、データが見つかりませんでした (data is null)。")
1054
+ return jsonify({"success": False, "message": f"指定されたコンテンツID '{content_id}' が見つかりません。"}), 404 # Not Found
1055
+ else:
1056
+ # success または data フィールド自体が欠落している場合
1057
+ logging.error(f"API /api/learning/{content_id}: GASからの応答形式が予期しないものです (success/data欠落)。 Response: {gas_result}")
1058
+ return jsonify({"success": False, "message": "データベースからの応答形式が不正です。"}), 500
1059
+
1060
+ except requests.exceptions.Timeout:
1061
+ logging.error(f"API /api/learning/{content_id}: GASへの接続がタイムアウトしました。")
1062
+ return jsonify({"success": False, "message": "データベースへの接続がタイムアウトしました。"}), 504 # Gateway Timeout
1063
+ except requests.exceptions.HTTPError as http_err:
1064
+ status_code = http_err.response.status_code if http_err.response else "N/A"
1065
+ response_text = http_err.response.text[:500] if http_err.response else "N/A"
1066
+ logging.error(f"API /api/learning/{content_id}: GASへの接続でHTTPエラーが発生: Status={status_code}, Error={http_err}. Response: {response_text}...")
1067
+ if status_code == 404:
1068
+ return jsonify({"success": False, "message": "データベースのエンドポイントが見つかりません。"}), 404
1069
+ else:
1070
+ return jsonify({"success": False, "message": f"データベース接続エラー (HTTP {status_code})。"}), 502 # Bad Gateway が適切か
1071
+ except requests.exceptions.RequestException as req_err:
1072
+ logging.error(f"API /api/learning/{content_id}: GASへの接続中にネットワークエラー等が発生: {req_err}")
1073
+ return jsonify({"success": False, "message": f"データベース接続エラー: {req_err}"}), 500
1074
+ except json.JSONDecodeError as json_err:
1075
+ raw_gas_response = "N/A"
1076
+ if 'gas_response' in locals() and hasattr(gas_response, 'text'):
1077
+ raw_gas_response = gas_response.text
1078
+ logging.error(f"API /api/learning/{content_id}: GASからの応答JSONのパースに失敗: {json_err}")
1079
+ logging.error(f"GAS Raw Response: {raw_gas_response[:500]}...")
1080
+ return jsonify({"success": False, "message": "データベースからの応答形式が不正です。"}), 500
1081
+ except Exception as e:
1082
+ logging.error(f"API /api/learning/{content_id}: コンテンツ取得中に予期せぬエラーが発生: {e}", exc_info=True)
1083
+ return jsonify({"success": False, "message": f"サーバー内部エラーが発生しました。"}), 500
1084
+
1085
+
1086
+ # --- アプリケーションの実行 ---
1087
+ if __name__ == '__main__':
1088
+ print("*"*60)
1089
+ print("Flaskアプリケーション起動準備")
1090
+ print("*"*60)
1091
+
1092
+ # --- 環境変数チェック ---
1093
+ print("環境変数チェック:")
1094
+ # 必須の環境変数リスト (キー名を修正)
1095
+ required_env_vars = ['GEMINI_API_KEY', 'DEEPSEEK_API_KEY', 'GAS_WEB_APP_URL']
1096
+ missing_vars = []
1097
+ env_vars_status = {}
1098
+
1099
+ # GEMINI_API_KEY チェック
1100
+ gemini_key = os.getenv('GEMINI_API_KEY') # <<< 修正 >>> キー名変更
1101
+ if not gemini_key:
1102
+ missing_vars.append('GEMINI_API_KEY')
1103
+ env_vars_status['GEMINI_API_KEY'] = "未設定"
1104
+ else:
1105
+ env_vars_status['GEMINI_API_KEY'] = "設定済み"
1106
+
1107
+ # DEEPSEEK_API_KEY チェック
1108
+ deepseek_key = os.getenv('DEEPSEEK_API_KEY') # <<< 修正 >>> キー名変更
1109
+ if not deepseek_key:
1110
+ missing_vars.append('DEEPSEEK_API_KEY')
1111
+ env_vars_status['DEEPSEEK_API_KEY'] = "未設定"
1112
+ else:
1113
+ env_vars_status['DEEPSEEK_API_KEY'] = "設定済み"
1114
+
1115
+ # GAS_WEB_APP_URL チェック (文字列チェックを削除)
1116
+ gas_url = os.getenv('GAS_WEB_APP_URL')
1117
+ if not gas_url: # 未設定または空文字列の場合のみチェック
1118
+ missing_vars.append('GAS_WEB_APP_URL')
1119
+ env_vars_status['GAS_WEB_APP_URL'] = "未設定"
1120
+ else: # 何らかの値が設定されていればOKとする
1121
+ # 設定されている場合は、その値をそのまま表示
1122
+ env_vars_status['GAS_WEB_APP_URL'] = f"設定済み: {gas_url}"
1123
+
1124
+ # 結果表示 (変更なし)
1125
+ print(f" GEMINI_API_KEY: {env_vars_status['GEMINI_API_KEY']}")
1126
+ print(f" DEEPSEEK_API_KEY: {env_vars_status['DEEPSEEK_API_KEY']}")
1127
+ print(f" GAS_WEB_APP_URL: {env_vars_status['GAS_WEB_APP_URL']}") # 設定されていればURLが表示される
1128
+
1129
+ # 未設定の場合の警告メッセージ (GAS_WEB_APP_URLの内容に関する警告は削除)
1130
+ if missing_vars:
1131
+ print("\n[警告] 以下の必須環境変数が設定されていません:")
1132
+ # missing_vars に GAS_WEB_APP_URL が含まれていれば表示される
1133
+ for var in missing_vars:
1134
+ print(f" - {var}")
1135
+ print("関連する機能が動作しない可能性があります。")
1136
+ else:
1137
+ # すべて設定されていればメッセージを表示
1138
+ print("\n必要な環境変数は設定されているようです。")
1139
+
1140
+ print("-"*60)
1141
+
1142
+ # --- 起動 --- (以降変更なし)
1143
+ # Hugging Face Spacesでは通常ポート7860が使われることが多い
1144
+ debug_mode = os.getenv('FLASK_DEBUG', 'False').lower() in ['true', '1']
1145
+ host = os.getenv('FLASK_HOST', '0.0.0.0') # Space内で公開するには 0.0.0.0 が必要
1146
+ port = int(os.getenv('PORT', 7860)) # Hugging Face Spacesは PORT 環境変数を設定することがある
1147
+
1148
+ print(f"Flaskサーバーを起動します...")
1149
+ print(f" モード: {'デバッグ' if debug_mode else '本番'}")
1150
+ print(f" ホスト: {host}")
1151
+ print(f" ポート: {port}")
1152
+ print(f" 一時ディレクトリ: {TEMP_DIR}") # <<< 追加 >>> 一時ディレクトリ確認
1153
+ try:
1154
+ import socket
1155
+ hostname = socket.gethostname()
1156
+ local_ip = socket.gethostbyname(hostname)
1157
+ print(f" アクセスURL (ローカル): http://127.0.0.1:{port}/ または http://{local_ip}:{port}/")
1158
+ print(f" アクセスURL (Hugging Face Space): Spaceの公開URLを確認してください。")
1159
+ except:
1160
+ print(f" アクセスURL (基本): http://{host}:{port}/")
1161
+ print("*"*60)
1162
+
1163
+ # アプリケーションの起動(デバッグモードは環境変数で制御)
1164
+ app.run(host=host, port=port, debug=debug_mode)
gemini_output.txt ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 皆さんこんにちは。みどうです。
2
+
3
+ 今回は、ミニPCで有名なメーカーであるMINISFORUMのPC、UN100Lを購入し、同じCPUであるIntel N100を搭載したTRIGKEY製のミニPCと比較しながらベンチマークを取り、その性能や特徴について詳しくレビューしていきます。
4
+
5
+ MINISFORUMは比較的高スペック寄りのミニPCを多く出しており、中華系ミニPC界隈では珍しく、日本の家電量販店でも取り扱いがあり、安心感があります。今回購入したUN100Lは、Intel N100を搭載した激安ミニPCです。メモリとSSDの容量の組み合わせで価格が変わりますが、最も安い8GBメモリと256GB SSDの組み合わせで、セールと合わせて27,980円で購入できました。
6
+
7
+ ## 開封と外観
8
+
9
+ まずは開封から。箱の一番上には説明書類が入っています。日本語キーボード使用時の注意点が書いてある紙、日本語ではなくよくわからない紙、多言語対応の説明書(日本語もちゃんと書いてあります)の順に入っています。
10
+
11
+ 本体は本当に手のひらサイズです。その他、ACアダプター、HDMIケーブル、予備のゴム足、2.5インチSSD取り付け用のネジが入っていました。ACアダプターは36Wです。
12
+
13
+ 本体は手のサイズより小さく、可愛いPCです。全体がシルバーで質感はなかなか良いです。天板はMINISFORUMのロゴと文字だけのシンプルなデザイン。正面はCMOSクリアスイッチ、電源スイッチ、USB3.2 Gen2 Type-Aが2つ、イヤホンジャックが付いています。側面は両サイドとも通気口だけ…と思わせておいて、microSDのスロットが付いています。背面は端子がいろいろあります。左から順にHDMI端子、USB3.2 Gen2 Type-Cポート、2.5Gbps LANポート、DisplayPort、USB2.0が2つ、ACアダプターのポートとなっています。
14
+
15
+ 特筆すべき点として、Type-Cのポートがデータ通信だけでなく、ディスプレイ出力およびPD給電に対応している点が挙げられます。PD対応のディスプレイを持っていたり、Type-Cのドッキングステーションを持っていれば、このPCを持ってきて繋ぐだけで直ぐに使えます。持ち運びやすい形状を活かすという意味で、とてもありがたいです。
16
+
17
+ また、USB Type-Aが計4つ付いているのも、今の時代としては多めです。しかも2.0と3.2が2つずつなので、使い分けできて便利です。底面は吸気口で、中身が透けています。重さも364.5gと非常に軽量です。
18
+
19
+ ## 内部構造
20
+
21
+ 4つのゴム足を引っ張って外し、その下のネジを外すと簡単にアクセスできます。ネジは想像以上に長く、外すとNVMeのSSDと空中配線が目立つ基板が出てきます。
22
+
23
+ SSDのメーカーはGokepuという聞いたことのないメーカーです。調べてみてもあまり情報がヒットしないので、Samsung製のようなハードな使い方をする場合、交換が必要かもしれません。
24
+
25
+ Intel製のWi-Fiチップも見えます。空中配線はここから出て、そのまま基板裏に引っ張られています。もう1つの空中配線は2.5インチSSD増設用のSATAケーブルです。これを使って蓋に2.5インチSSDが付けられるようです。使わない人でも、これが付いたままなので少し気になりますね。
26
+
27
+ さらに4つ小さいネジを外すと基板が取り外せます。裏側はCPUファンで覆われていてよく見えませんが、オンボードのメモリが取り付けられているようです。このPCはメモリスロットがないので、自分で増設したりはできません。N100自体が16GBまでしか対応していませんが、16GB欲しい人は最初から16GBのモデルを買う必要がある点は注意です。立派なCPUファンが付いていますが、TDP6Wの省電力CPUなので、おそらく過剰スペックでしょう。
28
+
29
+ PCの筐体はプラスチックですが、内側は金属フレームになっています。耐久性とWi-Fiのアンテナを兼ねているようです…と思っていましたが、Wi-Fiアンテナは別に付いているようなので、耐久性だけの金属フレームのようです。
30
+
31
+ Wi-Fiアンテナについては、裏側がCPUファンで覆われているせいで、表側のWi-Fiカードから長めの配線をする羽目になっています。半田付けされているので勝手に抜けたりはしませんが、分解すると少し危険です。
32
+
33
+ ## 性能評価
34
+
35
+ 初期起動はWindows 11 Homeのセットアップ画面に遷移します。無名メーカーの怪しげなPCと違って安心です。
36
+
37
+ ### スペック
38
+
39
+ * CPU: Intel N100 (4コア4スレッド、基本速度0.80GHz、最大3.4GHz、全コア動作時2.9GHz)
40
+ * メモリ: DDR5-4800 8GB (オンボード)
41
+ * SSD: Gokepu 256GB (PCIe 3.0 x1)
42
+ * GPU: Intel UHD Graphics
43
+
44
+ Windows 11 HomeのOEM版が入っていました。
45
+
46
+ ### ベンチマーク
47
+
48
+ * FF14ベンチ: 4085 (普通評価)
49
+ * FF15ベンチ: 800 (動作困難)
50
+ * Cinebench R23: マルチ2468、シングル900
51
+ * CPU-Z: マルチ1281、シングル383
52
+ * PassMark: CPU 5679、3D Graphics 831、Memory 2173、Disk 4505
53
+ * CrystalDiskMark: Seq Read/Write 850MB/s
54
+
55
+ オフィスソフトの起動はスムーズで、オフィスワークにストレスはあまりかからないでしょう。
56
+
57
+ ## TRIGKEY製N100ミニPCとの比較
58
+
59
+ 以前購入したTRIGKEY製N100搭載ミニPCと比較してみると、各ベンチマークでTRIGKEY製の方が性能が高いことがわかりました。しかし、消費電力的にはMINISFORUMが半分程度だったので、性能のTRIGKEY、消費電力のMINISFORUMという結果になりました。
60
+
61
+ MINISFORUM製は消費電力が低いのに加え、動作音も小さく、扱いやすいPCです。さすがにFF14ベンチは無理そうですが、公式の評価は「普通」で、軽いオフィス作業や動画鑑賞は余裕でこなせます。
62
+
63
+ ## まとめ
64
+
65
+ 価格、省電力性、性能、メーカーの安心感、どの視点から見ても良いPCだと思います。TRIGKEY製のミニPCのようにサーバーにする前提でLANが2つ付いている、といった特徴はありませんし、メモリ増設もできませんが、総合的にレベルの高いN100搭載機の決定版と言えるでしょう。
66
+
67
+ もし安くてそこそこ動くサブPCが欲しい方や、子供に初めてのPCとして渡してみたい方など、是非検討してみてはいかがでしょうか。
gemini_summary_output.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "s1",
4
+ "type": "summary",
5
+ "text": "ミニPCメーカーMINISFORUMのUM700を購入\n他メーカーと比較して国内での取り扱いがあり安心感がある"
6
+ },
7
+ {
8
+ "id": "s2",
9
+ "type": "summary",
10
+ "text": "UM700のスペック\nCPU:Intel N100\nメモリ:8GB/DDR5 4800MHz\nSSD:256GB"
11
+ },
12
+ {
13
+ "id": "s3",
14
+ "type": "summary",
15
+ "text": "外観:手のひらサイズ、シルバーで質感も良い\nインターフェース:USB Type-A x4, Type-C x1, HDMI x2, DisplayPort x1, イヤホンジャック, MicroSDスロット, 2.5インチSSD増設用SATAケーブル"
16
+ },
17
+ {
18
+ "id": "s4",
19
+ "type": "summary",
20
+ "text": "ベンチマーク結果\nFF14:普通評価\nFF15:動作困難\nCinebench R23:マルチ2468, シングル900\nCPU-Z:マルチ1281, シングル383\nPassMark:CPU 5679, 3D Graphics 831, Memory 2173, Disk 4505"
21
+ },
22
+ {
23
+ "id": "s5",
24
+ "type": "summary",
25
+ "text": "他社N100搭載機と比較\n消費電力は半分で温度も低い\n性能は1割程度低い\n総合的に見て価格・性能・消費電力のバランスが良い"
26
+ },
27
+ {
28
+ "id": "s6",
29
+ "type": "summary",
30
+ "text": "用途\nオフィスソフト:快適に動作\n動画編集:やや厳しい\n動画鑑賞:問題なし\nサブPCや子供用PCにおすすめ"
31
+ }
32
+ ]
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ numpy==1.23.5
4
+ requests
5
+ yt-dlp
6
+ google-generativeai
7
+ google-genai
8
+ openai
9
+ youtube-transcript-api
static/script.js ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // static/script.js
2
+
3
+ "use strict"; // より厳格なエラーチェック
4
+
5
+ // --- グローバル変数 ---
6
+ let learningData = null; // 学習データ (learning.html用)
7
+ let currentItemIndex = 0; // 現在表示中のアイテムインデックス (learning.html用)
8
+ let currentMode = 'quiz'; // 現在のモード 'quiz' or 'summary' (learning.html用)
9
+
10
+ // --- 共通関数 ---
11
+
12
+ /**
13
+ * 指定されたURLに遷移します。
14
+ * @param {string} url - 遷移先のURL
15
+ */
16
+ function navigateTo(url) {
17
+ window.location.href = url;
18
+ }
19
+
20
+ /**
21
+ * メニューボタンがクリックされたときの処理(仮)
22
+ * TODO: 実際のメニューUIを実装する
23
+ */
24
+ function openMenu() {
25
+ console.log("Menu button clicked. Implement menu display logic here.");
26
+ // 例: サイドバーメニューを表示する、モーダルを表示するなど
27
+ alert("メニュー機能は未実装です。\nフッターのナビゲーションを使用してください。");
28
+ }
29
+
30
+ /**
31
+ * ローディングスピナーを表示/非表示します。
32
+ * @param {boolean} show - trueで表示、falseで非表示
33
+ */
34
+ function toggleLoading(show) {
35
+ const spinner = document.querySelector('.loading-spinner'); // input.html用
36
+ const buttonText = document.querySelector('.button-text'); // input.html用
37
+ const generateButton = document.getElementById('generate-button'); // input.html用
38
+
39
+ if (spinner && buttonText && generateButton) {
40
+ spinner.style.display = show ? 'inline-block' : 'none';
41
+ buttonText.textContent = show ? '生成中...' : '生成する';
42
+ generateButton.disabled = show;
43
+ }
44
+ // learning.html 用のローディング表示/非表示も必要に応じて追加
45
+ const loadingCard = document.getElementById('loading-card-indicator'); // 仮のID
46
+ if (loadingCard) {
47
+ loadingCard.style.display = show ? 'block' : 'none';
48
+ }
49
+ }
50
+
51
+ /**
52
+ * エラーメッセージを表示します。
53
+ * @param {string} message - 表示するエラーメッセージ
54
+ * @param {string} elementId - メッセージを表示する要素のID (デフォルト: 'error-message')
55
+ */
56
+ function displayErrorMessage(message, elementId = 'error-message') {
57
+ const errorElement = document.getElementById(elementId);
58
+ if (errorElement) {
59
+ errorElement.textContent = message;
60
+ errorElement.style.display = message ? 'block' : 'none';
61
+ }
62
+ }
63
+
64
+ // --- 画面遷移用関数 ---
65
+ function goToInput() {
66
+ navigateTo('/'); // input.html はルートパスに割り当てる想定
67
+ }
68
+
69
+ function goToHistory() {
70
+ navigateTo('/history');
71
+ }
72
+
73
+ function goToSettings() {
74
+ navigateTo('/settings');
75
+ }
76
+
77
+ function goToLearning(contentId) {
78
+ if (contentId) {
79
+ navigateTo(`/learning?id=${encodeURIComponent(contentId)}`);
80
+ } else {
81
+ console.error('goToLearning requires a content ID.');
82
+ alert('学習コンテンツIDが見つかりません。');
83
+ }
84
+ }
85
+
86
+
87
+ // --- input.html 用の処理 ---
88
+
89
+ /**
90
+ * 生成フォームの送信処理
91
+ */
92
+ async function handleGenerateSubmit() {
93
+ const urlInput = document.getElementById('youtube-url');
94
+ const youtubeUrl = urlInput.value.trim();
95
+ displayErrorMessage(''); // 前のエラーメッセージをクリア
96
+
97
+ if (!youtubeUrl) {
98
+ displayErrorMessage('YouTubeリンクを入力してください。');
99
+ return false; // prevent default form submission
100
+ }
101
+
102
+ // 簡単なURL形式チェック(より厳密なチェックも可能)
103
+ if (!youtubeUrl.includes('youtube.com/') && !youtubeUrl.includes('youtu.be/')) {
104
+ displayErrorMessage('有効なYouTubeリンクを入力してください。');
105
+ return false;
106
+ }
107
+
108
+
109
+ toggleLoading(true); // ローディング開始
110
+
111
+ try {
112
+ const response = await fetch('/api/generate', {
113
+ method: 'POST',
114
+ headers: {
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ body: JSON.stringify({ url: youtubeUrl }),
118
+ });
119
+
120
+ const result = await response.json();
121
+
122
+ if (response.ok && result.success && result.data && result.data.id) {
123
+ // 成功したら学習画面へ遷移
124
+ alert('生成に成功しました!学習画面に移動します。');
125
+ goToLearning(result.data.id);
126
+ } else {
127
+ // 失敗したらエラーメッセージ表示
128
+ console.error('Generation failed:', result);
129
+ displayErrorMessage(result.message || '生成中にエラーが発生しました。');
130
+ }
131
+
132
+ } catch (error) {
133
+ console.error('Error during generation request:', error);
134
+ displayErrorMessage('通信エラーが発生しました。');
135
+ } finally {
136
+ toggleLoading(false); // ローディング終了
137
+ }
138
+
139
+ return false; // prevent default form submission
140
+ }
141
+
142
+
143
+ // --- learning.html 用の処理 ---
144
+
145
+ /**
146
+ * learning.html の初期化
147
+ */
148
+ async function initializeLearningScreen() {
149
+ console.log('Initializing Learning Screen...');
150
+ const params = new URLSearchParams(window.location.search);
151
+ const contentId = params.get('id');
152
+ const loadingIndicator = document.getElementById('mode-indicator'); // 仮にモード表示部を使う
153
+ const cardElement = document.getElementById('learning-card');
154
+ const paginationElement = document.querySelector('.pagination');
155
+
156
+ if (!contentId) {
157
+ displayLearningError('コンテンツIDが指定されていません。');
158
+ return;
159
+ }
160
+ console.log('Content ID:', contentId);
161
+
162
+ // ローディング表示(簡易版)
163
+ if (loadingIndicator) loadingIndicator.textContent = '読み込み中...';
164
+ if (cardElement) cardElement.style.opacity = '0.5'; // 少し薄くする
165
+ if (paginationElement) paginationElement.style.display = 'none';
166
+
167
+
168
+ try {
169
+ const response = await fetch(`/api/learning/${contentId}`);
170
+ if (!response.ok) {
171
+ const errorData = await response.json().catch(() => ({ message: `HTTPエラー: ${response.status}` }));
172
+ throw new Error(errorData.message || `サーバーからのデータ取得に失敗しました (${response.status})`);
173
+ }
174
+
175
+ const result = await response.json();
176
+ console.log('Fetched data:', result);
177
+
178
+ if (result.success && result.data) {
179
+ learningData = result.data; // グローバル変数に格納
180
+ if (!learningData.items || learningData.items.length === 0) {
181
+ throw new Error('学習データが空です。');
182
+ }
183
+ // タイトルを設定
184
+ const titleElement = document.getElementById('learning-title');
185
+ if (titleElement) {
186
+ titleElement.textContent = learningData.title || '学習コンテンツ';
187
+ }
188
+
189
+ // 最初のアイテムを表示
190
+ currentItemIndex = 0;
191
+ displayCurrentItem();
192
+ if (paginationElement) paginationElement.style.display = 'flex'; // ページネーション表示
193
+
194
+ } else {
195
+ throw new Error(result.message || '学習データの読み込みに失敗しました。');
196
+ }
197
+
198
+ } catch (error) {
199
+ console.error('Error initializing learning screen:', error);
200
+ displayLearningError(`読み込みエラー: ${error.message}`);
201
+ } finally {
202
+ // ローディング表示終了(簡易版)
203
+ if (cardElement) cardElement.style.opacity = '1';
204
+ // mode-indicatorはdisplayCurrentItemで更新される
205
+ }
206
+ }
207
+
208
+ /**
209
+ * 現在の学習アイテムをカードに表示
210
+ */
211
+ function displayCurrentItem() {
212
+ if (!learningData || !learningData.items || currentItemIndex < 0 || currentItemIndex >= learningData.items.length) {
213
+ console.error('Invalid learning data or index');
214
+ displayLearningError('学習データを表示できません。');
215
+ return;
216
+ }
217
+
218
+ const item = learningData.items[currentItemIndex];
219
+ const cardTextElement = document.getElementById('card-text');
220
+ const answerTextElement = document.getElementById('answer-text');
221
+ const tapToShowElement = document.getElementById('tap-to-show');
222
+ const optionsArea = document.getElementById('options-area');
223
+ const modeIndicator = document.getElementById('mode-indicator');
224
+
225
+ // リセット
226
+ cardTextElement.innerHTML = '';
227
+ answerTextElement.style.display = 'none';
228
+ tapToShowElement.style.display = 'none';
229
+ optionsArea.innerHTML = '';
230
+ optionsArea.style.display = 'none';
231
+
232
+ if (item.type === 'question') {
233
+ currentMode = 'quiz';
234
+ modeIndicator.textContent = 'クイズモード';
235
+ cardTextElement.textContent = item.text; // 質問文
236
+ answerTextElement.textContent = `答え: ${item.answer}`; // 答えを事前に設定(非表示)
237
+ tapToShowElement.style.display = 'block'; // タップして表示を表示
238
+
239
+ // 選択肢ボタンを生成(解答チェックはここではしない)
240
+ if (item.options && Array.isArray(item.options)) {
241
+ optionsArea.style.display = 'block';
242
+ item.options.forEach(option => {
243
+ const button = document.createElement('button');
244
+ button.classList.add('option-button');
245
+ button.textContent = option;
246
+ // ★★★ 選択肢クリック時の動作を変更: 正誤判定ではなく解答表示 ★★★
247
+ button.onclick = revealAnswer; // どのボタンを押しても解答表示
248
+ optionsArea.appendChild(button);
249
+ });
250
+ }
251
+
252
+ } else if (item.type === 'summary') {
253
+ currentMode = 'summary';
254
+ modeIndicator.textContent = '要約モード';
255
+ cardTextElement.innerHTML = item.text.replace(/\n/g, '<br>'); // 要約文(改行対応)
256
+
257
+ // 要約モードでは答えも選択肢も不要
258
+ } else {
259
+ console.warn('Unknown item type:', item.type);
260
+ cardTextElement.textContent = `[不明なデータタイプ: ${item.type}] ${item.text || ''}`;
261
+ modeIndicator.textContent = '不明モード';
262
+ }
263
+
264
+ updatePagination();
265
+ }
266
+
267
+ /**
268
+ * クイズの解答を表示する
269
+ */
270
+ function revealAnswer() {
271
+ // クイズモードの場合のみ動作
272
+ if (currentMode === 'quiz') {
273
+ const answerTextElement = document.getElementById('answer-text');
274
+ const tapToShowElement = document.getElementById('tap-to-show');
275
+ const optionsArea = document.getElementById('options-area');
276
+
277
+ if (answerTextElement) {
278
+ answerTextElement.style.display = 'block'; // 答えを表示
279
+ }
280
+ if (tapToShowElement) {
281
+ tapToShowElement.style.display = 'none'; // 「タップして表示」を隠す
282
+ }
283
+
284
+ // 選択肢ボタンを正解・不正解で色付け&無効化
285
+ if (optionsArea && learningData && learningData.items[currentItemIndex]) {
286
+ const correctAnswer = learningData.items[currentItemIndex].answer;
287
+ const buttons = optionsArea.getElementsByTagName('button');
288
+ for (let btn of buttons) {
289
+ btn.disabled = true; // ボタンを無効化
290
+ if (btn.textContent === correctAnswer) {
291
+ btn.classList.add('correct'); // 正解スタイル
292
+ } else {
293
+ btn.classList.add('disabled'); // 不正解または他の選択肢スタイル
294
+ }
295
+ // クリックイベントを削除(任意)
296
+ btn.onclick = null;
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+
303
+ /**
304
+ * 次のアイテムへ移動
305
+ */
306
+ function goToNext() {
307
+ if (learningData && currentItemIndex < learningData.items.length - 1) {
308
+ currentItemIndex++;
309
+ displayCurrentItem();
310
+ }
311
+ }
312
+
313
+ /**
314
+ * 前のアイテムへ移動
315
+ */
316
+ function goToPrev() {
317
+ if (learningData && currentItemIndex > 0) {
318
+ currentItemIndex--;
319
+ displayCurrentItem();
320
+ }
321
+ }
322
+
323
+ /**
324
+ * ページネーション表示を更新
325
+ */
326
+ function updatePagination() {
327
+ const pageInfo = document.getElementById('page-info');
328
+ const prevButton = document.getElementById('prev-button');
329
+ const nextButton = document.getElementById('next-button');
330
+
331
+ if (pageInfo && prevButton && nextButton && learningData && learningData.items) {
332
+ const totalItems = learningData.items.length;
333
+ pageInfo.textContent = `${currentItemIndex + 1} / ${totalItems}`;
334
+ prevButton.disabled = currentItemIndex === 0;
335
+ nextButton.disabled = currentItemIndex === totalItems - 1;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * learning.html でエラーを表示
341
+ */
342
+ function displayLearningError(message) {
343
+ const cardElement = document.getElementById('learning-card');
344
+ const titleElement = document.getElementById('learning-title');
345
+ const paginationElement = document.querySelector('.pagination');
346
+ const optionsArea = document.getElementById('options-area');
347
+ const modeIndicator = document.getElementById('mode-indicator');
348
+ const tapToShow = document.getElementById('tap-to-show');
349
+
350
+ if (titleElement) titleElement.textContent = 'エラー';
351
+ if (cardElement) cardElement.innerHTML = `<p class="main-text" style="color: red; text-align: center;">${message}</p>`;
352
+ if (paginationElement) paginationElement.style.display = 'none';
353
+ if (optionsArea) optionsArea.style.display = 'none';
354
+ if (modeIndicator) modeIndicator.style.display = 'none';
355
+ if (tapToShow) tapToShow.style.display = 'none';
356
+ }
357
+
358
+
359
+ // --- settings.html 用の処理 ---
360
+
361
+ /**
362
+ * トグルスイッチの変更ハンドラ
363
+ */
364
+ function handleToggleChange(checkbox, type) {
365
+ console.log(`Toggle changed for ${type}: ${checkbox.checked}`);
366
+ if (type === 'dark') {
367
+ document.body.classList.toggle('dark-mode', checkbox.checked);
368
+ try {
369
+ localStorage.setItem('darkModeEnabled', checkbox.checked);
370
+ } catch (e) {
371
+ console.warn('Could not save dark mode preference to localStorage.');
372
+ }
373
+ }
374
+ // 他のトグル(例: プッシュ通知)の処理もここに追加
375
+ }
376
+
377
+ /**
378
+ * ログアウトボタンの処理
379
+ */
380
+ function handleLogout() {
381
+ console.log("Logout clicked");
382
+ // TODO: 実際のログアウト処理(API呼び出し、セッションクリアなど)
383
+ alert("ログアウト機能は未実装です。");
384
+ // 必要であればログイン画面などに遷移
385
+ // navigateTo('/login');
386
+ }
387
+
388
+ /**
389
+ * ダークモード設定を読み込んで適用する
390
+ */
391
+ function applyDarkModePreference() {
392
+ try {
393
+ const darkModeEnabled = localStorage.getItem('darkModeEnabled') === 'true';
394
+ document.body.classList.toggle('dark-mode', darkModeEnabled);
395
+ // 設定画面のトグルスイッチの状態も合わせる
396
+ const toggle = document.querySelector('input[onchange*="handleToggleChange"][onchange*="dark"]');
397
+ if (toggle) {
398
+ toggle.checked = darkModeEnabled;
399
+ }
400
+ } catch (e) {
401
+ console.warn('Could not load dark mode preference from localStorage.');
402
+ }
403
+ }
404
+
405
+
406
+ // --- ページの初期化処理 ---
407
+ document.addEventListener('DOMContentLoaded', () => {
408
+ const pathname = window.location.pathname;
409
+
410
+ applyDarkModePreference(); // 全ページでダークモード設定を適用
411
+
412
+ if (pathname === '/' || pathname === '/input') {
413
+ // input.html の初期化(特に不要かもしれないが、フォームイベントリスナーなど)
414
+ const form = document.getElementById('generate-form');
415
+ if (form) {
416
+ form.addEventListener('submit', (event) => {
417
+ event.preventDefault(); // デフォルトの送信をキャンセル
418
+ handleGenerateSubmit();
419
+ });
420
+ }
421
+ } else if (pathname === '/learning') {
422
+ initializeLearningScreen();
423
+ } else if (pathname === '/history') {
424
+ // history.html の初期化(動的にリストを生成する場合など)
425
+ // このサンプルでは静的なので特に不要
426
+ } else if (pathname === '/settings') {
427
+ // settings.html の初期化(ダークモードの適用は applyDarkModePreference で実施済み)
428
+ }
429
+
430
+ // フッターナビゲーションのアクティブ状態設定(任意)
431
+ updateFooterNavActiveState(pathname);
432
+ });
433
+
434
+
435
+ /**
436
+ * フッターナビゲーションのアクティブ状態を更新
437
+ */
438
+ function updateFooterNavActiveState(pathname) {
439
+ const footerNav = document.querySelector('.footer-nav');
440
+ if (!footerNav) return;
441
+
442
+ const buttons = footerNav.querySelectorAll('button');
443
+ buttons.forEach(button => {
444
+ button.classList.remove('active'); // Reset all
445
+ const onclickAttr = button.getAttribute('onclick');
446
+ if (onclickAttr) {
447
+ if ((pathname === '/' || pathname === '/input') && onclickAttr.includes('goToInput')) {
448
+ button.classList.add('active');
449
+ } else if (pathname === '/history' && onclickAttr.includes('goToHistory')) {
450
+ button.classList.add('active');
451
+ } else if (pathname === '/settings' && onclickAttr.includes('goToSettings')) {
452
+ button.classList.add('active');
453
+ }
454
+ }
455
+ });
456
+ }
457
+
458
+
459
+ // デバッグ用に一部関数をグローバルスコープに公開(開発中のみ推奨)
460
+ window.debug = {
461
+ navigateTo,
462
+ goToInput,
463
+ goToHistory,
464
+ goToSettings,
465
+ goToLearning,
466
+ openMenu,
467
+ handleGenerateSubmit,
468
+ initializeLearningScreen,
469
+ revealAnswer,
470
+ goToNext,
471
+ goToPrev,
472
+ handleToggleChange,
473
+ handleLogout
474
+ };
static/style.css ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* 基本スタイル */
2
+ body {
3
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
4
+ margin: 0;
5
+ background-color: #f0f0f0;
6
+ display: flex; /* 中央揃えのため */
7
+ justify-content: center; /* 中央揃えのため */
8
+ align-items: flex-start; /* 上端揃え */
9
+ min-height: 100vh;
10
+ padding: 20px 0; /* 上下に余白 */
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ .screen {
15
+ width: 100%; /* 横幅いっぱい */
16
+ max-width: 400px; /* スマホ画面幅を想定 */
17
+ background-color: #fff;
18
+ border: 1px solid #ccc;
19
+ min-height: 700px; /* 高さを確保 */
20
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
21
+ display: flex;
22
+ flex-direction: column; /* 子要素を縦に並べる */
23
+ overflow: hidden; /* はみ出しを隠す */
24
+ }
25
+
26
+ .header {
27
+ display: flex;
28
+ align-items: center;
29
+ justify-content: space-between;
30
+ padding: 10px 15px;
31
+ background-color: #f8f8f8;
32
+ border-bottom: 1px solid #eee;
33
+ flex-shrink: 0; /* ヘッダーが縮まないように */
34
+ }
35
+
36
+ .header .menu-btn, .header .action-btn {
37
+ background: none;
38
+ border: none;
39
+ font-size: 24px;
40
+ cursor: pointer;
41
+ padding: 5px;
42
+ }
43
+
44
+ .header .action-btn {
45
+ width: 30px;
46
+ height: 30px;
47
+ background-color: #007bff; /* 青い丸 */
48
+ border-radius: 50%;
49
+ padding: 0; /* 内側の余白を削除 */
50
+ /* 必要ならアイコンや文字を配置 */
51
+ }
52
+
53
+ .header .title {
54
+ font-size: 18px;
55
+ font-weight: bold;
56
+ margin: 0;
57
+ text-align: center;
58
+ flex-grow: 1; /* 中央に配置するために */
59
+ }
60
+
61
+ main {
62
+ padding: 15px;
63
+ flex-grow: 1; /* 残りの高さを埋める */
64
+ overflow-y: auto; /* 内容が多い場合にスクロール */
65
+ }
66
+
67
+ /* List Styles */
68
+ ul.list {
69
+ list-style: none;
70
+ padding: 0;
71
+ margin: 0;
72
+ }
73
+
74
+ li.list-item {
75
+ border-bottom: 1px solid #eee;
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ padding: 0; /* paddingはボタン側で調整 */
80
+ }
81
+ li.list-item:last-child {
82
+ border-bottom: none;
83
+ }
84
+
85
+ /* ListItemをボタンとして使えるように */
86
+ .list-item-button {
87
+ display: flex;
88
+ align-items: center;
89
+ width: 100%;
90
+ padding: 12px 15px;
91
+ text-decoration: none;
92
+ color: inherit;
93
+ background-color: transparent;
94
+ border: none;
95
+ cursor: pointer;
96
+ text-align: left;
97
+ }
98
+ .list-item-button:hover {
99
+ background-color: #f9f9f9;
100
+ }
101
+
102
+ .list-item-content {
103
+ display: flex;
104
+ align-items: center;
105
+ flex-grow: 1; /* テキスト部分が幅を取るように */
106
+ margin-right: 10px; /* 矢印とのスペース */
107
+ }
108
+
109
+ .list-item-icon {
110
+ margin-right: 12px;
111
+ font-size: 22px;
112
+ color: #007bff; /* アイコンの色 */
113
+ width: 24px; /* 幅を固定して揃える */
114
+ text-align: center;
115
+ }
116
+
117
+ .list-item-text h3 {
118
+ font-size: 16px;
119
+ margin: 0 0 4px 0;
120
+ font-weight: 500;
121
+ white-space: nowrap; /* 長いタイトルを省略 */
122
+ overflow: hidden;
123
+ text-overflow: ellipsis;
124
+ }
125
+
126
+ .list-item-text p {
127
+ font-size: 13px;
128
+ color: #777;
129
+ margin: 0;
130
+ }
131
+
132
+ .list-arrow {
133
+ color: #ccc;
134
+ font-size: 20px;
135
+ flex-shrink: 0; /* 矢印が縮まないように */
136
+ }
137
+
138
+ /* Learning Screen Card */
139
+ .card {
140
+ background-color: #fff;
141
+ padding: 30px 20px; /* 少し調整 */
142
+ border-radius: 12px; /* 少し調整 */
143
+ border: 1px solid #eee; /* 境界線を追加 */
144
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
145
+ text-align: center;
146
+ margin: 15px 0; /* 少し調整 */
147
+ min-height: 180px; /* 少し調整 */
148
+ display: flex;
149
+ flex-direction: column;
150
+ justify-content: center;
151
+ cursor: pointer; /* タップ可能を示す */
152
+ }
153
+ .card p.main-text {
154
+ margin: 0;
155
+ line-height: 1.7; /* 行間調整 */
156
+ font-size: 16px;
157
+ }
158
+ .card p.answer-text {
159
+ margin-top: 15px;
160
+ font-size: 18px;
161
+ font-weight: bold;
162
+ color: #007bff;
163
+ }
164
+ .tap-to-show {
165
+ text-align: center;
166
+ color: #888;
167
+ font-size: 14px;
168
+ margin-top: 10px;
169
+ cursor: pointer; /* タップ可能を示す */
170
+ }
171
+
172
+ /* Pagination */
173
+ .pagination {
174
+ display: flex;
175
+ justify-content: space-between;
176
+ align-items: center;
177
+ padding: 15px 0 5px 0; /* 下の余白を少し減らす */
178
+ flex-shrink: 0; /* 縮まないように */
179
+ }
180
+ .pagination button {
181
+ background: none;
182
+ border: none;
183
+ font-size: 30px;
184
+ color: #007bff;
185
+ cursor: pointer;
186
+ padding: 5px 15px; /* タップしやすく */
187
+ }
188
+ .pagination button:disabled {
189
+ color: #ccc;
190
+ cursor: default;
191
+ }
192
+ .pagination span {
193
+ font-size: 16px;
194
+ font-weight: 500;
195
+ }
196
+
197
+ /* Settings Screen */
198
+ .settings-item {
199
+ display: flex;
200
+ justify-content: space-between;
201
+ align-items: center;
202
+ width: 100%;
203
+ }
204
+ .settings-item span {
205
+ flex-grow: 1;
206
+ padding-left: 12px; /* アイコンとのスペース */
207
+ }
208
+
209
+ /* Toggle Switch (簡易版) */
210
+ .toggle-switch {
211
+ position: relative;
212
+ display: inline-block;
213
+ width: 50px;
214
+ height: 24px;
215
+ cursor: pointer;
216
+ }
217
+ .toggle-switch input {
218
+ opacity: 0;
219
+ width: 0;
220
+ height: 0;
221
+ }
222
+ .slider {
223
+ position: absolute;
224
+ cursor: pointer;
225
+ top: 0;
226
+ left: 0;
227
+ right: 0;
228
+ bottom: 0;
229
+ background-color: #ccc;
230
+ transition: .3s;
231
+ border-radius: 24px;
232
+ }
233
+ .slider:before {
234
+ position: absolute;
235
+ content: "";
236
+ height: 18px;
237
+ width: 18px;
238
+ left: 3px;
239
+ bottom: 3px;
240
+ background-color: white;
241
+ transition: .3s;
242
+ border-radius: 50%;
243
+ }
244
+ input:checked + .slider {
245
+ background-color: #007bff;
246
+ }
247
+ input:checked + .slider:before {
248
+ transform: translateX(26px);
249
+ }
250
+
251
+ .section-title {
252
+ color: #666; /* 少し濃く */
253
+ font-size: 14px;
254
+ font-weight: 500;
255
+ margin: 20px 0 8px 0;
256
+ padding-left: 15px;
257
+ }
258
+
259
+ .logout {
260
+ color: red;
261
+ font-weight: bold;
262
+ }
263
+
264
+ /* Input Screen */
265
+ .input-area {
266
+ text-align: center;
267
+ padding-top: 40px; /* ヘッダーがない場合の余白 */
268
+ }
269
+ .input-area h2 {
270
+ font-size: 20px; /* 少し大きく */
271
+ font-weight: bold;
272
+ margin-bottom: 25px;
273
+ line-height: 1.4;
274
+ }
275
+ .input-area input[type="text"] {
276
+ width: calc(100% - 44px); /* 左右padding分引く */
277
+ padding: 12px 15px;
278
+ border: 1px solid #ccc;
279
+ border-radius: 8px;
280
+ font-size: 16px;
281
+ margin-bottom: 20px;
282
+ box-sizing: border-box;
283
+ }
284
+ .input-area .generate-button {
285
+ background-color: #ff3b30; /* iOS風の赤 */
286
+ color: white;
287
+ border: none;
288
+ padding: 12px 25px;
289
+ font-size: 16px;
290
+ font-weight: bold;
291
+ border-radius: 25px;
292
+ cursor: pointer;
293
+ display: inline-flex; /* アイコンとテキストを横並び */
294
+ align-items: center;
295
+ justify-content: center;
296
+ margin: 0 auto 25px auto;
297
+ transition: background-color 0.2s;
298
+ }
299
+ .input-area .generate-button:hover {
300
+ background-color: #e03024;
301
+ }
302
+ .input-area .generate-button:disabled {
303
+ background-color: #fca9a4;
304
+ cursor: not-allowed;
305
+ }
306
+ .input-area .generate-button .icon { /* 再生ボタン風アイコン */
307
+ margin-left: 8px;
308
+ font-size: 14px;
309
+ }
310
+ .input-area .loading-spinner {
311
+ border: 3px solid rgba(255, 255, 255, 0.3);
312
+ border-radius: 50%;
313
+ border-top-color: #fff;
314
+ width: 16px;
315
+ height: 16px;
316
+ animation: spin 1s linear infinite;
317
+ margin-right: 8px; /* テキストとのスペース */
318
+ }
319
+ @keyframes spin {
320
+ to { transform: rotate(360deg); }
321
+ }
322
+
323
+
324
+ .image-placeholder {
325
+ width: 85%; /* 画面幅に対する割合 */
326
+ max-width: 300px; /* 最大幅 */
327
+ aspect-ratio: 16 / 9; /* 縦横比 */
328
+ background-color: #e9e9e9;
329
+ border: 1px dashed #ccc;
330
+ display: flex;
331
+ align-items: center;
332
+ justify-content: center;
333
+ color: #aaa;
334
+ margin: 0 auto;
335
+ border-radius: 10px;
336
+ overflow: hidden; /* 画像表示用 */
337
+ }
338
+ .image-placeholder img {
339
+ display: block;
340
+ width: 100%;
341
+ height: 100%;
342
+ object-fit: cover;
343
+ }
344
+ .error-message {
345
+ color: red;
346
+ font-size: 13px;
347
+ margin-top: -15px; /* ボタンとの間を詰める */
348
+ margin-bottom: 15px;
349
+ min-height: 1em; /* エラーなくても高さを確保 */
350
+ }
351
+ /* static/style.css に追加 */
352
+
353
+ /* --- フッターナビゲーション --- */
354
+ .footer-nav {
355
+ position: fixed;
356
+ bottom: 0;
357
+ left: 0;
358
+ width: 100%;
359
+ background-color: #f8f8f8;
360
+ border-top: 1px solid #e0e0e0;
361
+ display: flex;
362
+ justify-content: space-around;
363
+ padding: 4px 0 8px 0; /* 上下少し調整 */
364
+ box-shadow: 0 -1px 4px rgba(0,0,0,0.08);
365
+ z-index: 1000;
366
+ }
367
+
368
+ .footer-nav button {
369
+ background: none;
370
+ border: none;
371
+ cursor: pointer;
372
+ padding: 5px;
373
+ display: flex;
374
+ flex-direction: column;
375
+ align-items: center;
376
+ font-size: 10px;
377
+ color: #888; /* 非アクティブ時の色 */
378
+ flex-grow: 1; /* ボタンが均等に幅を取るように */
379
+ transition: color 0.2s ease;
380
+ }
381
+
382
+ .footer-nav .nav-icon {
383
+ font-size: 22px;
384
+ margin-bottom: 2px;
385
+ }
386
+
387
+ .footer-nav button.active {
388
+ color: #007bff; /* アクティブ時の色 (例: 青) */
389
+ }
390
+
391
+ /* --- main要素の底上げ --- */
392
+ /* フッターにコンテンツが隠れないように */
393
+ main {
394
+ padding-bottom: 70px; /* フッターの高さに応じて調整 */
395
+ }
396
+
397
+ /* --- ローディングスピナー (input.html用) --- */
398
+ .loading-spinner {
399
+ border: 3px solid rgba(0, 0, 0, 0.1);
400
+ border-left-color: #fff; /* スピナーの色 */
401
+ border-radius: 50%;
402
+ width: 16px;
403
+ height: 16px;
404
+ animation: spin 1s linear infinite;
405
+ display: inline-block; /* ボタン内で表示 */
406
+ margin-left: 8px; /* ボタンテキストとの間隔 */
407
+ vertical-align: middle;
408
+ }
409
+
410
+ @keyframes spin {
411
+ 0% { transform: rotate(0deg); }
412
+ 100% { transform: rotate(360deg); }
413
+ }
414
+
415
+ /* --- learning.html のローディング(簡易版)--- */
416
+ /* 必要であれば .loading-spinner-large などを定義 */
417
+
418
+
419
+ /* --- learning.html の解答ボタン スタイル --- */
420
+ .option-button.correct {
421
+ background-color: #d4edda; /* 緑系 */
422
+ color: #155724;
423
+ border-color: #c3e6cb;
424
+ }
425
+
426
+ .option-button.incorrect { /* これは revealAnswer では使わないかも */
427
+ background-color: #f8d7da; /* 赤系 */
428
+ color: #721c24;
429
+ border-color: #f5c6cb;
430
+ }
431
+
432
+ .option-button.disabled {
433
+ background-color: #e9ecef;
434
+ color: #6c757d;
435
+ border-color: #ced4da;
436
+ opacity: 0.7;
437
+ }
438
+
439
+
440
+ /* --- ダークモード用スタイル (一部) --- */
441
+ body.dark-mode {
442
+ background-color: #121212;
443
+ color: #e0e0e0;
444
+ }
445
+ body.dark-mode .screen { background-color: #1e1e1e; }
446
+ body.dark-mode .header { background-color: #1f1f1f; border-bottom-color: #333; }
447
+ body.dark-mode .header .title, body.dark-mode .header .menu-btn { color: #e0e0e0; }
448
+ body.dark-mode .footer-nav { background-color: #1f1f1f; border-top-color: #333; }
449
+ body.dark-mode .footer-nav button { color: #888; }
450
+ body.dark-mode .footer-nav button.active { color: #58a6ff; } /* ダークモードでのアクティブ色 */
451
+ body.dark-mode .card { background-color: #2c2c2c; border-color: #444; color: #e0e0e0; }
452
+ body.dark-mode .list-item-button { background-color: #2c2c2c; border-bottom-color: #444; }
453
+ body.dark-mode .list-item-text h3, body.dark-mode .list-item-text p { color: #e0e0e0; }
454
+ body.dark-mode .list-arrow { color: #aaa; }
455
+ body.dark-mode .settings-item span { color: #e0e0e0; }
456
+ body.dark-mode .section-title { color: #aaa; }
457
+ body.dark-mode input[type="text"] { background-color: #333; border-color: #555; color: #e0e0e0; }
458
+ body.dark-mode .generate-button { background-color: #3081d8; color: white; }
459
+ body.dark-mode .option-button { background-color: #444; color: #e0e0e0; border-color: #666; }
460
+ body.dark-mode .option-button.correct { background-color: #2a6831; color: #e0e0e0; border-color: #41984b; }
461
+ body.dark-mode .option-button.disabled { background-color: #333; color: #888; border-color: #555; opacity: 0.7; }
462
+ body.dark-mode .pagination button:disabled { color: #666; }
463
+ body.dark-mode .toggle-switch .slider { background-color: #555; }
464
+ body.dark-mode .toggle-switch input:checked + .slider { background-color: #58a6ff; }
465
+ /* 他の要素も必要に応じてダークモードスタイルを追加 */
templates/history.html ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>履歴</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ <style>
9
+ /* サムネイル用のスタイルを追加 (任意) */
10
+ .list-item-thumbnail {
11
+ width: 80px; /* 幅を調整 */
12
+ height: 45px; /* 高さを調整 (16:9) */
13
+ object-fit: cover; /* 画像のアスペクト比を保ちつつコンテナにフィット */
14
+ margin-right: 10px; /* テキストとの間隔 */
15
+ border-radius: 4px; /* 角丸 */
16
+ flex-shrink: 0; /* コンテナが縮んでも画像サイズを維持 */
17
+ }
18
+ .list-item-content {
19
+ display: flex; /* サムネイルとテキストを横並び */
20
+ align-items: center; /* 垂直方向中央揃え */
21
+ flex-grow: 1; /* 利用可能なスペースを埋める */
22
+ overflow: hidden; /* はみ出したタイトルを隠す */
23
+ }
24
+ .list-item-text h3 {
25
+ white-space: nowrap; /* タイトルを1行に */
26
+ overflow: hidden; /* はみ出しを隠す */
27
+ text-overflow: ellipsis; /* はみ出しを...で表示 */
28
+ margin-bottom: 4px; /* 日付との間隔 */
29
+ }
30
+ .list-item-text p {
31
+ font-size: 0.8em; /* 日付のフォントサイズ */
32
+ color: #666; /* 日付の色 */
33
+ margin: 0;
34
+ }
35
+ .list-item-empty, .list-item-error {
36
+ text-align: center;
37
+ color: #888;
38
+ padding: 20px;
39
+ }
40
+ .list-item-error {
41
+ color: red;
42
+ }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <div class="screen">
47
+ <header class="header">
48
+ <button class="menu-btn" aria-label="メニュー" onclick="openMenu()">☰</button>
49
+ <h1 class="title">履歴</h1>
50
+ <button class="action-btn" aria-label="アクション"></button> <!-- 右上のボタンは不要なら削除 -->
51
+ </header>
52
+ <main>
53
+ <ul class="list" id="history-list">
54
+ {# エラーメッセージがあれば表示 #}
55
+ {% if error_message %}
56
+ <li class="list-item-error">履歴の取得に失敗しました: {{ error_message }}</li>
57
+ {% endif %}
58
+
59
+ {# 履歴アイテムがあればループ処理 #}
60
+ {% if history_items %}
61
+ {% for item in history_items %}
62
+ <li class="list-item">
63
+ {# 各アイテムに学習ページへのリンクを設定 #}
64
+ <a href="{{ url_for('learning_page', id=item.id) }}" class="list-item-button">
65
+ <div class="list-item-content">
66
+ {# サムネイルがあれば表示、なければデフォルトアイコン #}
67
+ {% if item.thumbnail %}
68
+ <img src="{{ item.thumbnail }}" alt="Thumbnail" class="list-item-thumbnail" onerror="this.style.display='none'; this.nextElementSibling.style.display='inline-block';"> {# 画像読み込み失敗時の代替アイコン表示用 #}
69
+ <span class="list-item-icon" style="display: none;">📄</span> {# 代替アイコン (最初は非表示) #}
70
+ {% else %}
71
+ {# typeがないのでデフォルトアイコン #}
72
+ <span class="list-item-icon">📄</span>
73
+ {% endif %}
74
+ <div class="list-item-text">
75
+ {# タイトルと日付を表示 #}
76
+ <h3>{{ item.title }}</h3>
77
+ <p>{{ item.date }}</p>
78
+ </div>
79
+ </div>
80
+ <span class="list-arrow">></span>
81
+ </a>
82
+ </li>
83
+ {% endfor %}
84
+ {# 履歴アイテムがなく、エラーもない場合は「履歴なし」メッセージ #}
85
+ {% elif not error_message %}
86
+ <li class="list-item-empty">履歴はありません。</li>
87
+ {% endif %}
88
+ </ul>
89
+ </main>
90
+ <!-- フッターナビゲーション -->
91
+ <footer class="footer-nav">
92
+ <button onclick="goToInput()" aria-label="入力">
93
+ <span class="nav-icon">➕</span>
94
+ <span class="nav-text">入力</span>
95
+ </button>
96
+ <button onclick="goToHistory()" aria-label="履歴" class="active"> {# 現在のページをアクティブに #}
97
+ <span class="nav-icon">🕒</span>
98
+ <span class="nav-text">履歴</span>
99
+ </button>
100
+ <button onclick="goToSettings()" aria-label="設定">
101
+ <span class="nav-icon">⚙️</span>
102
+ <span class="nav-text">設定</span>
103
+ </button>
104
+ </footer>
105
+ </div>
106
+ {# script.js はナビゲーション用などに必要なら残す #}
107
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
108
+ </body>
109
+ </html>
templates/input.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>動画からクイズ&要約生成</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ </head>
9
+ <!-- 例: フッターナビゲーション (各HTMLの </body> の直前に追加) -->
10
+ <footer class="footer-nav">
11
+ <button onclick="goToInput()" aria-label="入力">
12
+ <span class="nav-icon">➕</span> <!-- アイコンは好みで変更 -->
13
+ <span class="nav-text">入力</span>
14
+ </button>
15
+ <button onclick="goToHistory()" aria-label="履歴">
16
+ <span class="nav-icon">🕒</span>
17
+ <span class="nav-text">履歴</span>
18
+ </button>
19
+ <button onclick="goToSettings()" aria-label="設定">
20
+ <span class="nav-icon">⚙️</span>
21
+ <span class="nav-text">設定</span>
22
+ </button>
23
+ </footer>
24
+ <body>
25
+ <div class="screen">
26
+ <!-- この画面にはヘッダーなし -->
27
+ <main class="input-area">
28
+ <h2>動画リンクから<br>クイズ&要約を自動生成!</h2>
29
+
30
+ <form id="generate-form">
31
+ <input type="text" id="youtube-url" placeholder="ここにYouTubeリンクをペースト" required aria-label="YouTubeリンク">
32
+ <div class="error-message" id="error-message"></div>
33
+ <button type="submit" class="generate-button" id="generate-button">
34
+ <span class="button-text">生成する</span>
35
+ <span class="icon">▶</span>
36
+ <div class="loading-spinner" style="display: none;"></div>
37
+ </button>
38
+ </form>
39
+
40
+ <div class="image-placeholder" id="image-placeholder">
41
+ <span>(イメージ表示エリア)</span>
42
+ <!-- <img src="..." alt="動画サムネイル" style="display: none;"> -->
43
+ </div>
44
+ </main>
45
+ <!-- フッターナビゲーションなどを追加する場合はここに -->
46
+ </div>
47
+
48
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
49
+ </body>
50
+ </html>
templates/learning.html ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>学習画面</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ </head>
9
+ <!-- 例: フッターナビゲーション (各HTMLの </body> の直前に追加) -->
10
+ <footer class="footer-nav">
11
+ <button onclick="goToInput()" aria-label="入力">
12
+ <span class="nav-icon">➕</span> <!-- アイコンは好みで変更 -->
13
+ <span class="nav-text">入力</span>
14
+ </button>
15
+ <button onclick="goToHistory()" aria-label="履歴">
16
+ <span class="nav-icon">🕒</span>
17
+ <span class="nav-text">履歴</span>
18
+ </button>
19
+ <button onclick="goToSettings()" aria-label="設定">
20
+ <span class="nav-icon">⚙️</span>
21
+ <span class="nav-text">設定</span>
22
+ </button>
23
+ </footer>
24
+ <body>
25
+ <div class="screen">
26
+ <header class="header">
27
+ <button class="menu-btn" aria-label="メニュー" onclick="openMenu()">☰</button>
28
+ <h1 class="title" id="learning-title">学習セット: 面白い動画の分析</h1>
29
+ <!-- 右上のボタンは不要なら削除 -->
30
+ <button class="action-btn" aria-label="アクション"></button>
31
+ </header>
32
+ <main>
33
+ <p style="text-align: center; color: #555; font-size: 14px; margin-bottom: 10px;" id="mode-indicator">クイズモード</p>
34
+
35
+ <div class="card" id="learning-card" onclick="revealAnswer()">
36
+ <p class="main-text" id="card-text">ここに表示される問題文または要約テキストはカードの中心に来るように調整されます。(サンプルテキスト)</p>
37
+ <p class="answer-text" id="answer-text" style="display: none;">答え: 正解の選択肢</p>
38
+ </div>
39
+
40
+ <p class="tap-to-show" id="tap-to-show" onclick="revealAnswer()">タップして解答を表示</p>
41
+
42
+ <!-- クイズの選択肢表示エリア (JSで動的に生成) -->
43
+ <div id="options-area" style="margin-top: 15px;">
44
+ <!-- 例:
45
+ <button class="option-button">A: 選択肢A</button>
46
+ <button class="option-button">B: 選択肢B</button>
47
+ ...
48
+ -->
49
+ </div>
50
+
51
+ <div class="pagination">
52
+ <button id="prev-button" aria-label="前へ" onclick="goToPrev()"><</button>
53
+ <span id="page-info">5 / 10</span>
54
+ <button id="next-button" aria-label="次へ" onclick="goToNext()">></button>
55
+ </div>
56
+ </main>
57
+ </div>
58
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
59
+
60
+ </body>
61
+ </html>
templates/settings.html ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ja">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>設定</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ </head>
9
+ <!-- 例: フッターナビゲーション (各HTMLの </body> の直前に追加) -->
10
+ <footer class="footer-nav">
11
+ <button onclick="goToInput()" aria-label="入力">
12
+ <span class="nav-icon">➕</span> <!-- アイコンは好みで変更 -->
13
+ <span class="nav-text">入力</span>
14
+ </button>
15
+ <button onclick="goToHistory()" aria-label="履歴">
16
+ <span class="nav-icon">🕒</span>
17
+ <span class="nav-text">履歴</span>
18
+ </button>
19
+ <button onclick="goToSettings()" aria-label="設定">
20
+ <span class="nav-icon">⚙️</span>
21
+ <span class="nav-text">設定</span>
22
+ </button>
23
+ </footer>
24
+ <body>
25
+ <div class="screen">
26
+ <header class="header">
27
+ <button class="menu-btn" aria-label="メニュー" onclick="openMenu()">☰</button>
28
+ <h1 class="title">設定</h1>
29
+ <!-- 右上のボタンは不要なら削除 -->
30
+ <button class="action-btn" aria-label="アクション"></button>
31
+ </header>
32
+ <main>
33
+ <ul class="list">
34
+ <li class="list-item">
35
+ <a href="#" class="list-item-button" onclick="alert('アカウント情報画面へ(未実装)')">
36
+ <div class="settings-item">
37
+ <span>アカウント情報</span>
38
+ <span class="list-arrow">></span>
39
+ </div>
40
+ </a>
41
+ </li>
42
+ <li class="list-item" style="padding: 12px 15px;"> <!-- ボタンにしない場合 -->
43
+ <div class="settings-item">
44
+ <span>プッシュ通知</span>
45
+ <label class="toggle-switch">
46
+ <input type="checkbox" checked onchange="handleToggleChange(this, 'push')">
47
+ <span class="slider"></span>
48
+ </label>
49
+ </div>
50
+ </li>
51
+ <li class="list-item" style="padding: 12px 15px;">
52
+ <div class="settings-item">
53
+ <span>ダークモード</span>
54
+ <label class="toggle-switch">
55
+ <input type="checkbox" onchange="handleToggleChange(this, 'dark')">
56
+ <span class="slider"></span>
57
+ </label>
58
+ </div>
59
+ </li>
60
+ </ul>
61
+
62
+ <h3 class="section-title">その他</h3>
63
+ <ul class="list">
64
+ <li class="list-item">
65
+ <a href="#" class="list-item-button" onclick="alert('アプリについて画面へ(未実装)')">
66
+ <div class="settings-item">
67
+ <span>このアプリについて</span>
68
+ <span class="list-arrow">></span>
69
+ </div>
70
+ </a>
71
+ </li>
72
+ <li class="list-item">
73
+ <button class="list-item-button" style="color: red;" onclick="handleLogout()">
74
+ <div class="settings-item">
75
+ <span class="logout">ログアウト</span>
76
+ </div>
77
+ </button>
78
+ </li>
79
+ </ul>
80
+ </main>
81
+ </div>
82
+ <script src="{{ url_for('static', filename='script.js') }}"></script>
83
+ </body>
84
+ </html>