Spaces:
Sleeping
Sleeping
| """ | |
| 超天才クイズ v3 - FastAPI Backend | |
| HuggingFace Spaces Docker SDK | |
| Difyを排除し、Gemini APIを直接呼び出すアーキテクチャ | |
| """ | |
| import os | |
| import json | |
| import logging | |
| import asyncio | |
| from pathlib import Path | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import FileResponse | |
| from pydantic import BaseModel | |
| from typing import List, Optional, Dict | |
| from dotenv import load_dotenv | |
| # .envファイルを読み込み | |
| load_dotenv() | |
| # サービス | |
| from src.services.gemini_service import GeminiService | |
| from src.services.gas_client import GASClient, extract_exclude_keywords | |
| # KnowledgeService削除 - QuestionDB駆動に移行(v1.6.0) | |
| from src.services.auth_service import AuthService | |
| from src.services.questiondb_service import QuestionDBService | |
| # ロギング設定 | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # FastAPIアプリ | |
| app = FastAPI( | |
| title="超天才クイズ v3 API", | |
| description="中学受験対策4択クイズ - Gemini直接統合版", | |
| version="3.0.0" | |
| ) | |
| # サービス初期化 | |
| gemini_service = GeminiService() | |
| gas_client = GASClient() | |
| # KnowledgeService削除 - QuestionDB駆動に移行(v1.6.0) | |
| questiondb_service = QuestionDBService(gas_client=gas_client, gemini_service=gemini_service) | |
| # ============================================================================= | |
| # リクエスト/レスポンスモデル | |
| # ============================================================================= | |
| class RegisterUserRequest(BaseModel): | |
| username: str | |
| password: str | |
| invite_code: str | |
| class StartSessionRequest(BaseModel): | |
| user_id: str | |
| subjects: List[str] | |
| class GenerateQuestionsRequest(BaseModel): | |
| session_id: str | |
| subjects: List[str] | |
| user_id: Optional[str] = None # v1.4.0: 要約・ジャンルカウント取得用 | |
| class Answer(BaseModel): | |
| question_id: str | |
| selected_answer: int | |
| user_answer: Optional[int] = None # GAS APIに渡す用(数値型で統一) | |
| correct_answer: Optional[int] = None # GAS APIに渡す用 | |
| subject: Optional[str] = None | |
| category: Optional[str] = None | |
| time_taken_seconds: Optional[int] = 0 | |
| class SubmitAnswersRequest(BaseModel): | |
| session_id: str | |
| answers: List[Answer] | |
| class GetStatisticsRequest(BaseModel): | |
| user_id: str | |
| subjects: Optional[List[str]] = None | |
| class GetEvaluationRequest(BaseModel): | |
| session_id: str | |
| subjects: Optional[List[str]] = None | |
| class LoginRequest(BaseModel): | |
| username: str | |
| password: str | |
| # ============================================================================= | |
| # バックグラウンドタスク | |
| # ============================================================================= | |
| async def background_summary_generation( | |
| session_id: str, | |
| validated_by_subject: Dict[str, List[Dict]] | |
| ): | |
| """ | |
| バックグラウンドでサマリー生成→GAS保存 | |
| Args: | |
| session_id: セッションID | |
| validated_by_subject: 教科別問題辞書 | |
| """ | |
| try: | |
| logger.info(f"background_summary_generation: Starting for session_id={session_id}") | |
| # 各教科のサマリーを生成 | |
| for subject, questions in validated_by_subject.items(): | |
| if not questions: | |
| continue | |
| try: | |
| # Geminiでサマリー生成 | |
| logger.info(f"background_summary_generation: Generating summary for {subject} ({len(questions)} questions)") | |
| summary_data = await gemini_service.generate_summary(subject, questions) | |
| # GASに保存 | |
| logger.info(f"background_summary_generation: Saving summary for {subject} to GAS") | |
| save_result = await gas_client.save_summary( | |
| session_id=session_id, | |
| subject=subject, | |
| summary_data=summary_data | |
| ) | |
| if save_result.get("success"): | |
| logger.info(f"background_summary_generation: Summary saved successfully for {subject}") | |
| else: | |
| logger.warning(f"background_summary_generation: Failed to save summary for {subject}: {save_result}") | |
| except Exception as subject_error: | |
| logger.error(f"background_summary_generation: Error processing {subject}: {subject_error}") | |
| # 1教科の失敗は他に影響させない | |
| continue | |
| logger.info(f"background_summary_generation: Completed for session_id={session_id}") | |
| except Exception as e: | |
| logger.error(f"background_summary_generation: Fatal error for session_id={session_id}: {e}") | |
| # バックグラウンドタスクなので例外は握りつぶす | |
| # ============================================================================= | |
| # APIエンドポイント | |
| # ============================================================================= | |
| async def health_check(): | |
| """ヘルスチェック""" | |
| return { | |
| "status": "healthy", | |
| "version": "3.0.0", | |
| "services": { | |
| "gemini": gemini_service.is_available(), | |
| "gas": gas_client.is_available(), | |
| "questiondb": True # QuestionDB駆動に移行(v1.6.0) | |
| } | |
| } | |
| async def register_user(request: RegisterUserRequest): | |
| """ユーザー登録""" | |
| try: | |
| # 招待コード検証 | |
| valid_invite_code = os.environ.get("INVITE_CODE", "") | |
| if not valid_invite_code: | |
| logger.error("register_user: INVITE_CODE environment variable is not set") | |
| raise HTTPException(status_code=403, detail="招待コードが設定されていません") | |
| if request.invite_code != valid_invite_code: | |
| logger.warning(f"register_user: Invalid invite code attempt for user '{request.username}'") | |
| raise HTTPException(status_code=403, detail="招待コードが無効です") | |
| logger.info(f"register_user: Invite code verified for user '{request.username}'") | |
| # パスワードをハッシュ化 | |
| hashed_password = AuthService.hash_password(request.password) | |
| logger.info(f"register_user: Password hashed for user '{request.username}'") | |
| # GAS連携処理(ハッシュ化されたパスワードを渡す) | |
| result = await gas_client.register_user(request.username, hashed_password) | |
| # 既存ユーザーの場合はエラー | |
| if result.get("data", {}).get("is_new") == False: | |
| raise HTTPException(status_code=409, detail="このユーザー名は既に登録されています") | |
| return result | |
| except HTTPException: | |
| # HTTPExceptionはそのまま再送出 | |
| raise | |
| except Exception as e: | |
| logger.error(f"register_user error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def login(request: LoginRequest): | |
| """ログイン""" | |
| try: | |
| # GASからユーザー情報取得 | |
| result = await gas_client.login(request.username) | |
| # ユーザーが見つからない場合 | |
| if not result.get("found"): | |
| logger.warning(f"login: User not found: '{request.username}'") | |
| raise HTTPException(status_code=401, detail="ユーザー名またはパスワードが正しくありません") | |
| data = result.get("data", {}) | |
| stored_hash = data.get("password_hash") | |
| # パスワード未設定の既存ユーザー(v1.0からの移行ユーザー) | |
| if not stored_hash: | |
| logger.info(f"login: User '{request.username}' needs password migration") | |
| raise HTTPException( | |
| status_code=403, | |
| detail="パスワードが未設定です。新規登録画面からパスワードを設定してください。" | |
| ) | |
| # パスワード検証 | |
| if not AuthService.verify_password(request.password, stored_hash): | |
| logger.warning(f"login: Invalid password for user '{request.username}'") | |
| raise HTTPException(status_code=401, detail="ユーザー名またはパスワードが正しくありません") | |
| logger.info(f"login: User '{request.username}' logged in successfully") | |
| return { | |
| "success": True, | |
| "data": { | |
| "user_id": data.get("user_id"), | |
| "username": data.get("username") | |
| } | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"login error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def start_session(request: StartSessionRequest): | |
| """セッション開始""" | |
| try: | |
| result = await gas_client.start_session(request.user_id, request.subjects) | |
| return result | |
| except Exception as e: | |
| logger.error(f"start_session error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def generate_questions(request: GenerateQuestionsRequest): | |
| """問題生成 - QuestionDB駆動(高速版) | |
| v1.6.0: QuestionDB駆動に移行 | |
| - Before: Gemini API 2回呼び出し(問題生成+検証)→ 20-60秒 | |
| - After: QuestionDB取得 + Gemini 1回(選択肢のみ)→ 2-5秒 | |
| """ | |
| try: | |
| logger.info(f"generate_questions: Starting QuestionDB-driven generation for {len(request.subjects)} subjects: {request.subjects}") | |
| # Phase 0: 統計・要約取得(優先ジャンル・除外ID用) | |
| priority_genres = None | |
| exclude_ids = None | |
| if request.user_id: | |
| try: | |
| # 統計取得(ジャンルカウント含む) | |
| stats_result = await gas_client.get_statistics(request.user_id, request.subjects) | |
| priority_genres = stats_result.get("data", {}).get("priority_genres", {}) | |
| logger.info(f"generate_questions: Got priority_genres for {len(priority_genres)} subjects") | |
| # 要約取得(重複問題除外用) | |
| summaries_result = await gas_client.get_question_summaries(request.user_id, limit=5) | |
| exclude_keywords = extract_exclude_keywords(summaries_result.get("data", summaries_result)) | |
| # TODO: exclude_keywords から exclude_ids に変換する処理(将来実装) | |
| logger.info(f"generate_questions: Got {len(exclude_keywords)} exclude keywords") | |
| except Exception as e: | |
| logger.warning(f"generate_questions: Failed to get stats/summaries (non-fatal): {e}") | |
| # Phase 1: QuestionDBから問題取得 + 選択肢生成 | |
| # v1.8: user_idがある場合はユーザー単位USAGE_COUNTを使用 | |
| logger.info(f"generate_questions: Fetching questions from QuestionDB (user_id={request.user_id})") | |
| questions_by_subject = await questiondb_service.get_questions_from_db( | |
| subjects=request.subjects, | |
| count_per_subject=10, | |
| priority_genres=priority_genres, | |
| exclude_ids=exclude_ids, | |
| user_id=request.user_id # v1.8: ユーザー単位USAGE_COUNT | |
| ) | |
| # 取得結果の確認 | |
| total_generated = sum(len(qs) for qs in questions_by_subject.values()) | |
| logger.info(f"generate_questions: Retrieved {total_generated} questions from DB") | |
| # 取得失敗の確認 | |
| failed_subjects = [s for s in request.subjects if len(questions_by_subject.get(s, [])) == 0] | |
| if len(failed_subjects) == len(request.subjects): | |
| raise HTTPException( | |
| status_code=500, | |
| detail="QuestionDatabaseから問題を取得できませんでした。もう一度お試しください。" | |
| ) | |
| # Phase 2: 並列保存 | |
| async def save_for_subject(subject: str, questions: list): | |
| """教科ごとのGAS保存処理""" | |
| saved = await gas_client.save_questions( | |
| session_id=request.session_id, | |
| subject=subject, | |
| questions=questions | |
| ) | |
| logger.info(f"generate_questions: Saved {len(saved)} questions for '{subject}'") | |
| return {"subject": subject, "saved": saved} | |
| save_tasks = [ | |
| save_for_subject(subject, questions) | |
| for subject, questions in questions_by_subject.items() | |
| if questions # 空でない場合のみ保存 | |
| ] | |
| save_results = await asyncio.gather(*save_tasks, return_exceptions=True) | |
| # 教科名マッピング | |
| subject_names = { | |
| "jp": "国語", | |
| "math": "算数", | |
| "sci": "理科", | |
| "soc": "社会" | |
| } | |
| # 保存結果を集約(元データ + question_id マージ) | |
| all_questions = [] | |
| logger.info(f"generate_questions: Question save summary - total_subjects={len(save_results)}") | |
| for idx, result in enumerate(save_results): | |
| if isinstance(result, Exception): | |
| logger.error(f"generate_questions: Save failed for task {idx}: {result}") | |
| else: | |
| subject = result.get("subject", "unknown") | |
| saved = result.get("saved", []) | |
| original = questions_by_subject.get(subject, []) # 元のGemini生成データ | |
| # question_id をマージして完全なデータを構築 | |
| for i, orig_q in enumerate(original): | |
| if i < len(saved): | |
| orig_q["question_id"] = saved[i].get("question_id") | |
| orig_q["subject"] = subject | |
| orig_q["subject_name"] = subject_names.get(subject, subject) | |
| all_questions.extend(original) | |
| logger.info(f"generate_questions: Saved summary - subject={subject}, saved_count={len(original)}") | |
| logger.info(f"generate_questions: All questions saved - total_count={len(all_questions)}") | |
| # Phase 3: usage_count更新 | |
| # v1.6.15: GAS側で抽出時に更新するため、ここでの呼び出しは不要 | |
| # (抽出時更新により、Gemini生成失敗時もカウントされるがシンプルさを優先) | |
| # Phase 4: バックグラウンドでサマリー生成(UIレスポンスを待たせない) | |
| asyncio.create_task( | |
| background_summary_generation( | |
| session_id=request.session_id, | |
| validated_by_subject=questions_by_subject | |
| ) | |
| ) | |
| logger.info(f"generate_questions: Background summary generation task created") | |
| # レスポンス構築 | |
| response_data = { | |
| "session_id": request.session_id, | |
| "questions": all_questions, | |
| "total_count": len(all_questions) | |
| } | |
| if failed_subjects: | |
| response_data["warnings"] = { | |
| "failed_subjects": failed_subjects, | |
| "message": f"以下の教科で問題取得に失敗しました: {', '.join(failed_subjects)}" | |
| } | |
| logger.warning(f"generate_questions: Partial success - failed subjects: {failed_subjects}") | |
| logger.info(f"generate_questions: Completed with {len(all_questions)} total questions") | |
| return { | |
| "success": True, | |
| "data": response_data | |
| } | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| logger.error(f"generate_questions error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def submit_answers(request: SubmitAnswersRequest): | |
| """解答送信""" | |
| try: | |
| result = await gas_client.submit_answers( | |
| session_id=request.session_id, | |
| answers=[a.model_dump() for a in request.answers] | |
| ) | |
| logger.info(f"[submit_answers] GAS response: {result}") | |
| return result | |
| except Exception as e: | |
| logger.error(f"submit_answers error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_statistics(request: GetStatisticsRequest): | |
| """統計取得 | |
| v1.6.13: GASからのcumulative/subjectsをそのまま使用するように修正 | |
| """ | |
| try: | |
| result = await gas_client.get_statistics( | |
| user_id=request.user_id, | |
| subjects=request.subjects | |
| ) | |
| # GASレスポンスをフロントエンド期待形式に変換 | |
| if result.get("success"): | |
| gas_data = result.get("data", {}) | |
| logger.debug(f"get_statistics: GAS response keys: {gas_data.keys()}") | |
| # v1.6.13: GASからの cumulative と subjects をそのまま使用 | |
| # GASは以下の形式で返す: | |
| # { | |
| # "user_id": "...", | |
| # "subjects": [...], | |
| # "radar_chart": {...}, | |
| # "cumulative": { "total_sessions", "total_questions", "total_correct", "overall_accuracy" } | |
| # } | |
| gas_cumulative = gas_data.get("cumulative", {}) | |
| gas_subjects = gas_data.get("subjects", []) | |
| logger.debug(f"get_statistics: cumulative from GAS: {gas_cumulative}") | |
| logger.debug(f"get_statistics: subjects count from GAS: {len(gas_subjects)}") | |
| # 教科名マッピング(GASにない場合のフォールバック用) | |
| subject_names = { | |
| "jp": "国語", | |
| "math": "算数", | |
| "sci": "理科", | |
| "soc": "社会" | |
| } | |
| # ジャンル名マッピング(CLAUDE.md準拠) | |
| genre_names = { | |
| # 国語 | |
| "JP01": "漢字・語彙(読み書き、四字熟語、慣用句、ことわざ)", | |
| "JP02": "文法・言葉のきまり(品詞、敬語、文の成分、修飾関係)", | |
| "JP03": "物語文読解(心情理解、場面把握、人物関係)", | |
| "JP04": "説明文・論説文読解(要旨、段落構成、筆者の主張)", | |
| "JP05": "随筆文読解(筆者の体験・感想の読み取り)", | |
| "JP06": "詩・韻文(詩、短歌、俳句、表現技法)", | |
| "JP07": "記述問題(理由説明、要約、意見記述)", | |
| "JP08": "知識・文学史(作家、作品名、文学的常識)", | |
| # 算数 | |
| "MA01": "計算", | |
| "MA02": "数の性質", | |
| "MA03": "割合・比", | |
| "MA04": "速さ", | |
| "MA05": "文章題(その他)", | |
| "MA06": "平面図形", | |
| "MA07": "立体図形", | |
| "MA08": "場合の数・確率", | |
| "MA09": "グラフ・表", | |
| "MA10": "特殊算", | |
| # 理科 | |
| "SC01": "力・運動", | |
| "SC02": "電気", | |
| "SC03": "光・音・熱", | |
| "SC04": "物質の性質", | |
| "SC05": "水溶液", | |
| "SC06": "燃焼・化学変化", | |
| "SC07": "植物", | |
| "SC08": "動物", | |
| "SC09": "人体", | |
| "SC10": "天体", | |
| "SC11": "気象", | |
| "SC12": "地学", | |
| # 社会 | |
| "SO01": "日本地理(国土・自然)", | |
| "SO02": "日本地理(産業)", | |
| "SO03": "世界地理", | |
| "SO04": "歴史(古代〜平安)", | |
| "SO05": "歴史(鎌倉〜室町)", | |
| "SO06": "歴史(安土桃山〜江戸)", | |
| "SO07": "歴史(明治〜現代)", | |
| "SO08": "公民(政治・憲法)", | |
| "SO09": "公民(経済・国際)", | |
| "SO10": "時事問題" | |
| } | |
| # subjects配列を処理(ジャンル名を補完) | |
| subjects_list = [] | |
| for subj in gas_subjects: | |
| # subject_nameがない場合はマッピングから取得 | |
| if not subj.get("subject_name"): | |
| subj["subject_name"] = subject_names.get(subj.get("subject", ""), subj.get("subject", "")) | |
| # genresのジャンル名を補完 | |
| genres = subj.get("genres", []) | |
| for genre in genres: | |
| if not genre.get("genre_name") or genre.get("genre_name") == "不明": | |
| genre["genre_name"] = genre_names.get(genre.get("genre_id", ""), genre.get("genre_id", "")) | |
| # total_attemptedフィールドを追加(フロントエンド互換) | |
| if "total_attempted" not in subj: | |
| subj["total_attempted"] = subj.get("total_questions", 0) | |
| subjects_list.append(subj) | |
| # 累積統計を構築(GASからのデータを使用) | |
| cumulative = { | |
| "total_sessions": gas_cumulative.get("total_sessions", 0), | |
| "total_questions": gas_cumulative.get("total_questions", 0), | |
| "overall_accuracy": gas_cumulative.get("overall_accuracy", 0.0) | |
| } | |
| transformed_result = { | |
| "success": True, | |
| "data": { | |
| "cumulative": cumulative, | |
| "subjects": subjects_list | |
| } | |
| } | |
| logger.info(f"get_statistics: Returning cumulative={cumulative}") | |
| return transformed_result | |
| else: | |
| # GASがエラーを返した場合はそのまま返す | |
| return result | |
| except Exception as e: | |
| logger.error(f"get_statistics error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_evaluation(request: GetEvaluationRequest): | |
| """評価生成 - Gemini API直接呼び出し(一括評価対応) | |
| v1.4.0: 要約ステータス・クイズ内容表示対応 | |
| """ | |
| try: | |
| # セッションの統計・結果を取得 | |
| logger.info(f"get_evaluation: Fetching session results for {request.session_id}") | |
| session_data = await gas_client.get_session_results(request.session_id) | |
| logger.info(f"get_evaluation: Session data received: {session_data.get('success', 'no success key')}") | |
| # v1.4.0: 要約ステータスチェック | |
| summary_status = None | |
| quiz_summary = None | |
| try: | |
| summary_result = await gas_client.check_summary_status(request.session_id) | |
| if summary_result.get("success") or summary_result.get("completed"): | |
| summary_status = summary_result | |
| # 要約テキストを構築(今回のクイズ内容) | |
| summaries = summary_result.get("summaries", []) | |
| if summaries: | |
| quiz_summary = "、".join([s.get("summary", "") for s in summaries if s.get("summary")]) | |
| logger.info(f"get_evaluation: Summary status - completed: {summary_result.get('completed')}, count: {summary_result.get('count')}") | |
| except Exception as e: | |
| logger.warning(f"get_evaluation: Failed to get summary status (non-fatal): {e}") | |
| # セッションデータの検証 | |
| if not session_data.get('success') and not session_data.get('data'): | |
| logger.warning(f"get_evaluation: No valid session data for {request.session_id}") | |
| return { | |
| "success": False, | |
| "error": "セッションデータが見つかりません" | |
| } | |
| # GASからのデータを取得 | |
| raw_data = session_data.get('data', session_data) | |
| # resultsをresults_by_subject形式に変換 | |
| results = raw_data.get('results', []) | |
| results_by_subject = {} | |
| for r in results: | |
| subject = r.get('subject', '') | |
| if subject: | |
| if subject not in results_by_subject: | |
| results_by_subject[subject] = [] | |
| results_by_subject[subject].append(r) | |
| # 変換したデータ構造を作成 | |
| transformed_data = { | |
| 'session_id': raw_data.get('session_id'), | |
| 'results': results, | |
| 'results_by_subject': results_by_subject, | |
| 'summary': raw_data.get('summary', {}), | |
| 'statistics': {} # 必要に応じて統計データも追加 | |
| } | |
| logger.info(f"get_evaluation: Transformed data - subjects: {list(results_by_subject.keys())}, total_results: {len(results)}") | |
| # Gemini APIで評価生成(一括評価) | |
| logger.info(f"get_evaluation: Generating batch evaluation via Gemini") | |
| batch_result = await gemini_service.generate_evaluation_batch( | |
| session_data=transformed_data | |
| ) | |
| # 結果をフロントエンド期待形式に変換 | |
| subject_evaluations = batch_result.get("subject_evaluations", {}) | |
| overall_evaluation = batch_result.get("overall_evaluation") | |
| # evaluations配列を構築(フロントエンド互換) | |
| evaluations = [] | |
| for subject, eval_data in subject_evaluations.items(): | |
| evaluations.append({ | |
| "subject": subject, | |
| **eval_data | |
| }) | |
| # 全体評価があれば追加 | |
| if overall_evaluation: | |
| evaluations.append({ | |
| "subject": "overall", | |
| **overall_evaluation | |
| }) | |
| logger.info(f"get_evaluation: Generated {len(evaluations)} evaluations (including overall)") | |
| # GASに評価を保存(エラーがあってもフロントエンドには成功を返す) | |
| try: | |
| save_result = await gas_client.save_evaluations( | |
| session_id=request.session_id, | |
| evaluations=evaluations | |
| ) | |
| logger.info(f"get_evaluation: Save result: {save_result.get('success', 'unknown')}") | |
| except Exception as save_error: | |
| logger.error(f"get_evaluation: Failed to save evaluations: {save_error}") | |
| # 保存に失敗しても、生成した評価は返す | |
| # v1.4.0: 要約情報を含めたレスポンス構築 | |
| response_data = { | |
| "session_id": request.session_id, | |
| "evaluations": evaluations | |
| } | |
| if summary_status: | |
| response_data["summary_status"] = { | |
| "completed": summary_status.get("completed", False), | |
| "count": summary_status.get("count", 0) | |
| } | |
| if quiz_summary: | |
| response_data["quiz_summary"] = quiz_summary | |
| return { | |
| "success": True, | |
| "data": response_data | |
| } | |
| except Exception as e: | |
| logger.error(f"get_evaluation error: {e}") | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # ============================================================================= | |
| # 静的ファイル配信 | |
| # ============================================================================= | |
| # 静的ファイル(CSS, JS) | |
| app.mount("/css", StaticFiles(directory="static/css"), name="css") | |
| app.mount("/js", StaticFiles(directory="static/js"), name="js") | |
| async def root(): | |
| """メインページ""" | |
| return FileResponse("static/index.html") | |
| # ============================================================================= | |
| # エントリーポイント | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| import uvicorn | |
| port = int(os.environ.get("PORT", 7860)) | |
| uvicorn.run( | |
| app, | |
| host="0.0.0.0", | |
| port=port, | |
| timeout_keep_alive=300 # タイムアウト300秒に延長 | |
| ) | |