ChoTensai_V3 / app.py
TOMOCHIN4
feat: v1.8.0 ユーザー単位USAGE_COUNT機能
9353c1e
"""
超天才クイズ 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エンドポイント
# =============================================================================
@app.get("/api/health")
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)
}
}
@app.post("/api/register_user")
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))
@app.post("/api/login")
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))
@app.post("/api/start_session")
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))
@app.post("/api/generate_questions")
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))
@app.post("/api/submit_answers")
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))
@app.post("/api/get_statistics")
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))
@app.post("/api/get_evaluation")
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")
@app.get("/")
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秒に延長
)