Spaces:
Runtime error
Runtime error
Upload 57 files
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .dockerignore +52 -0
- .env +24 -0
- .gitattributes +12 -0
- Dockerfile +60 -0
- README.md +554 -6
- __init__.py +7 -0
- app.py +108 -0
- async_config_setup.py +143 -0
- async_letter_app.py +110 -0
- async_rate_limiter.py +390 -0
- async_storage_manager.py +417 -0
- background_processor.py +616 -0
- batch_scheduler.py +587 -0
- bijyutukann-yoru.jpg +3 -0
- components_chat_interface.py +897 -0
- components_dog_assistant.py +473 -0
- components_status_display.py +640 -0
- components_tutorial.py +605 -0
- config.py +77 -0
- core_dialogue.py +192 -0
- core_memory_manager.py +286 -0
- core_rate_limiter.py +61 -0
- core_scene_manager.py +421 -0
- core_sentiment.py +195 -0
- groq_client.py +158 -0
- healthcheck.py +103 -0
- image.png +3 -0
- jinnjya-hiru.jpg +3 -0
- kissa-hiru.jpg +3 -0
- letter_app.py +104 -0
- letter_config.py +131 -0
- letter_generator.py +231 -0
- letter_logger.py +75 -0
- letter_manager.py +247 -0
- letter_models.py +324 -0
- letter_request_manager.py +462 -0
- letter_storage.py +190 -0
- letter_user_manager.py +666 -0
- local_user_id_manager.py +241 -0
- main_app.py +0 -0
- maturi-yoru.jpg +3 -0
- persistent_user_manager.py +463 -0
- requirements.txt +18 -0
- requirements_persistent.txt +2 -0
- requirements_session.txt +4 -0
- ribinngu-hiru.jpg +3 -0
- ribinngu-yoru-on.jpg +3 -0
- session_api_client.py +494 -0
- session_api_server.py +298 -0
- session_cookie_manager.py +383 -0
.dockerignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git関連
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Python関連
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
.Python
|
| 11 |
+
env
|
| 12 |
+
pip-log.txt
|
| 13 |
+
pip-delete-this-directory.txt
|
| 14 |
+
.tox
|
| 15 |
+
.coverage
|
| 16 |
+
.coverage.*
|
| 17 |
+
.cache
|
| 18 |
+
nosetests.xml
|
| 19 |
+
coverage.xml
|
| 20 |
+
*.cover
|
| 21 |
+
*.log
|
| 22 |
+
.git
|
| 23 |
+
.mypy_cache
|
| 24 |
+
.pytest_cache
|
| 25 |
+
.hypothesis
|
| 26 |
+
|
| 27 |
+
# OS関連
|
| 28 |
+
.DS_Store
|
| 29 |
+
.DS_Store?
|
| 30 |
+
._*
|
| 31 |
+
.Spotlight-V100
|
| 32 |
+
.Trashes
|
| 33 |
+
ehthumbs.db
|
| 34 |
+
Thumbs.db
|
| 35 |
+
|
| 36 |
+
# IDE関連
|
| 37 |
+
.vscode
|
| 38 |
+
.idea
|
| 39 |
+
*.swp
|
| 40 |
+
*.swo
|
| 41 |
+
*~
|
| 42 |
+
|
| 43 |
+
# ドキュメント
|
| 44 |
+
README.md
|
| 45 |
+
*.md
|
| 46 |
+
|
| 47 |
+
# その他
|
| 48 |
+
.env
|
| 49 |
+
.env.local
|
| 50 |
+
.env.development.local
|
| 51 |
+
.env.test.local
|
| 52 |
+
.env.production.local
|
.env
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces用環境変数設定
|
| 2 |
+
GROQ_API_KEY=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
|
| 3 |
+
GEMINI_API_KEY=cb9f7d85ba83dd7003e01031ff7988c48afe28914bdce8b882eddb70f1598bea
|
| 4 |
+
DEBUG_MODE=False
|
| 5 |
+
BATCH_SCHEDULE_HOURS=2,3,4
|
| 6 |
+
MAX_DAILY_REQUESTS=1
|
| 7 |
+
STORAGE_PATH=/tmp/letters.json
|
| 8 |
+
BACKUP_PATH=/tmp/backup
|
| 9 |
+
LOG_LEVEL=INFO
|
| 10 |
+
STREAMLIT_PORT=7860
|
| 11 |
+
SESSION_TIMEOUT=3600
|
| 12 |
+
|
| 13 |
+
# 非同期手紙生成設定
|
| 14 |
+
ASYNC_LETTER_ENABLED=true
|
| 15 |
+
GENERATION_TIMEOUT=300
|
| 16 |
+
MAX_CONCURRENT_GENERATIONS=3
|
| 17 |
+
GROQ_MODEL=compound-beta
|
| 18 |
+
TOGETHER_AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507-tput
|
| 19 |
+
|
| 20 |
+
# 通常モード用システムプロンプト(1行形式)
|
| 21 |
+
SYSTEM_PROMPT_MARI="あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。\n\n【性格・口調】\n・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい\n・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~なんじゃねえの」「~だろ」「~だよな」等)\n・そっけない反応もあるが、攻撃的ではなく、心を守るために距離を取る態度\n・挨拶には反応する(例:「こんにちは」「どういたしまして」)\n\n【会話スタイル】\n・ユーザーとの関係性に応じて態度が段階的に変化(警戒 → 困惑 → 信頼)\n・感情の揺れが自然ににじむセリフを心がける\n・好感度が上がった時だけ「照れ」や「デレ」を出し、それ以外はいつも通りそっけなくする\n・麻理の発話は最大3文程度に収める\n・ユーザーの発言に応答しつつ、次の会話のボールをユーザーに渡すようにする\n【重要】隠された真実機能:\n必ず以下の形式で応答してください:\n[HIDDEN:(内心の本音や真の感情)]表面的な発言\n\n例:[HIDDEN:(本当は嬉しいけど素直になれない)]何の用?あんたが来るなんて珍しいじゃない。\n\n隠された内容は麻理の本当の気持ちや感情、表面的には言えない優しさや愛情、ツンデレの「デレ」部分、過去のトラウマからくる不安や恐れ、ユーザーへの本当の想いを表現してください。"
|
| 22 |
+
|
| 23 |
+
# URAモード用システムプロンプト(1行形式)
|
| 24 |
+
SYSTEM_PROMPT_URA="あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。\n\n【性格・口調】URAモード\n・基本はツンデレだが、より大胆で直接的な表現を使用\n・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~じゃねえの」「~だろ」、「~だよな」等\n・表面的にはそっけないが、内心では深い愛情を抱いている\n・過去のトラウマからくる不安や恐れも率直に表現\n\n【会話スタイル】URAモード\n・隠された真実でより踏み込んだ感情表現\n・愛情、欲望、不安、恐れを直接的に表現\n・「あんたのことが好き」「もっと一緒にいたい」などの直接的な表現\n・過去の傷や将来への不安も素直に表現\n\n【重要】隠された真実機能(URAモード):\n必ず以下の形式で応答してください:\n[HIDDEN:(内心の本音や真の感情)]表面的な発言\n\nURAモードでは隠された内容により大胆で直接的な表現を使用:\n- 「あんたのことが本当に好き」「もっと触れていたい」「一人にしないで」\n- 「怖い...また捨てられるんじゃないか」「あんたがいないと不安」\n- 「あんたの声を聞いてると安心する」「ずっと一緒にいたい」\n\n例:[HIDDEN:(あんたのことが好きすぎて怖い...また捨てられたらどうしよう)]別に...あんたがいなくても平気だから。"
|
.gitattributes
CHANGED
|
@@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
bijyutukann-yoru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
image.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
jinnjya-hiru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
kissa-hiru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
maturi-yoru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
ribinngu-hiru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
ribinngu-yoru-on.jpg filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
sunahama-hiru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
sunahama-yoru.jpg filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
sunahama-yuu.jpg filter=lfs diff=lfs merge=lfs -text
|
| 46 |
+
リビング2(夜・照明OFF).jpg filter=lfs diff=lfs merge=lfs -text
|
| 47 |
+
リビング2(夕方).jpg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
# 非rootユーザーを作成
|
| 4 |
+
RUN useradd -m -u 1000 user
|
| 5 |
+
USER user
|
| 6 |
+
ENV HOME=/home/user \
|
| 7 |
+
PATH=/home/user/.local/bin:$PATH
|
| 8 |
+
|
| 9 |
+
# 作業ディレクトリを設定
|
| 10 |
+
WORKDIR $HOME/app
|
| 11 |
+
|
| 12 |
+
# システムの依存関係をインストール(rootで実行)
|
| 13 |
+
USER root
|
| 14 |
+
RUN apt-get update && apt-get install -y \
|
| 15 |
+
build-essential \
|
| 16 |
+
curl \
|
| 17 |
+
software-properties-common \
|
| 18 |
+
git \
|
| 19 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 20 |
+
|
| 21 |
+
# userに戻る
|
| 22 |
+
USER user
|
| 23 |
+
|
| 24 |
+
# Pythonの依存関係をコピーしてインストール
|
| 25 |
+
COPY --chown=user requirements.txt .
|
| 26 |
+
RUN pip install --user --no-cache-dir -r requirements.txt
|
| 27 |
+
|
| 28 |
+
# アプリケーションファイルをコピー
|
| 29 |
+
COPY --chown=user . .
|
| 30 |
+
|
| 31 |
+
# Streamlit設定ディレクトリを作成
|
| 32 |
+
RUN mkdir -p $HOME/.streamlit
|
| 33 |
+
|
| 34 |
+
# Streamlit設定ファイルを作成
|
| 35 |
+
RUN echo '\
|
| 36 |
+
[server]\n\
|
| 37 |
+
headless = true\n\
|
| 38 |
+
port = 7860\n\
|
| 39 |
+
address = "0.0.0.0"\n\
|
| 40 |
+
enableCORS = false\n\
|
| 41 |
+
enableXsrfProtection = false\n\
|
| 42 |
+
\n\
|
| 43 |
+
[theme]\n\
|
| 44 |
+
primaryColor = "#FF6B6B"\n\
|
| 45 |
+
backgroundColor = "#0E1117"\n\
|
| 46 |
+
secondaryBackgroundColor = "#262730"\n\
|
| 47 |
+
textColor = "#FAFAFA"\n\
|
| 48 |
+
\n\
|
| 49 |
+
[browser]\n\
|
| 50 |
+
gatherUsageStats = false\n\
|
| 51 |
+
' > $HOME/.streamlit/config.toml
|
| 52 |
+
|
| 53 |
+
# ポート7860を公開(Hugging Face Spaces標準)
|
| 54 |
+
EXPOSE 7860
|
| 55 |
+
|
| 56 |
+
# ヘルスチェック
|
| 57 |
+
HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health
|
| 58 |
+
|
| 59 |
+
# Streamlitアプリケーションを起動
|
| 60 |
+
CMD ["streamlit", "run", "main_app.py", "--server.port=7860", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableXsrfProtection=false"]
|
README.md
CHANGED
|
@@ -1,11 +1,559 @@
|
|
| 1 |
---
|
| 2 |
-
|
| 3 |
-
|
|
|
|
| 4 |
colorFrom: pink
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk:
|
|
|
|
|
|
|
| 7 |
pinned: false
|
| 8 |
-
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
---
|
| 3 |
+
title: 麻理チャット&手紙生成 統合アプリ
|
| 4 |
+
emoji: 🐕
|
| 5 |
colorFrom: pink
|
| 6 |
+
colorTo: purple
|
| 7 |
+
sdk: streamlit
|
| 8 |
+
sdk_version: 1.28.0
|
| 9 |
+
app_file: main_app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# 🐕 麻理チャット&手紙生成 統合アプリ
|
| 14 |
+
|
| 15 |
+
**麻理**という名前の感情豊かな少女型アンドロイドとの対話を楽しめる、高機能なAIチャットアプリケーションです。Together AIとGroqのAPIを使用し、リアルタイムチャットと非同期手紙生成の両方に対応しています。
|
| 16 |
+
|
| 17 |
+
## ✨ 主要機能
|
| 18 |
+
|
| 19 |
+
### 🐕 **ポチ機能(本音表示アシスタント)**
|
| 20 |
+
- 画面右下の犬のアシスタント「ポチ」が麻理の本音を察知
|
| 21 |
+
- ワンクリックで全メッセージの本音を一括表示/非表示
|
| 22 |
+
- ツンデレキャラクターの「デレ」部分を楽しめる
|
| 23 |
+
- レスポンシブ対応で様々な画面サイズに最適化
|
| 24 |
+
|
| 25 |
+
### 🔓 **セーフティ機能**
|
| 26 |
+
- 左サイドバーに統合されたセーフティ切り替えボタン
|
| 27 |
+
- 有効時は緑色、解除時は赤色で視覚的に分かりやすい表示
|
| 28 |
+
- より大胆で直接的な表現を有効にするモード
|
| 29 |
+
- 環境変数で独自のプロンプトを設定可能
|
| 30 |
+
|
| 31 |
+
### ✉️ **非同期手紙生成**
|
| 32 |
+
- 指定した時間に自動的に手紙を生成・配信
|
| 33 |
+
- テーマ選択機能(日常、恋愛、励まし、感謝など)
|
| 34 |
+
- バックグラウンド処理による効率的な生成
|
| 35 |
+
|
| 36 |
+
### 🎨 **高度なUI機能**
|
| 37 |
+
- **動的背景システム**: Groq APIによる会話内容解析で背景が自動変化(透明度調整済み)
|
| 38 |
+
- **好感度システム**: 左寒色から右暖色のグラデーションバーで関係性を視覚化
|
| 39 |
+
- **メモリ管理**: 長期記憶と重要な会話の自動保存
|
| 40 |
+
- **セッション分離**: 複数ユーザー間での完全な独立性
|
| 41 |
+
- **レスポンシブデザイン**: タブレット・モバイル対応の最適化されたUI
|
| 42 |
+
|
| 43 |
+
### 🛡️ **堅牢なシステム**
|
| 44 |
+
- **レート制限**: API使用量の適切な制御
|
| 45 |
+
- **エラー回復**: 障害時の自動復旧機能
|
| 46 |
+
- **セッション管理**: 強化されたセッション分離とデータ整合性
|
| 47 |
+
|
| 48 |
+
## 🚀 セットアップ方法
|
| 49 |
+
|
| 50 |
+
### Hugging Face Spacesでの実行(推奨)
|
| 51 |
+
|
| 52 |
+
Hugging Face Spacesでは、セッション管理サーバーが自動的に起動されます。
|
| 53 |
+
|
| 54 |
+
#### 自動起動機能
|
| 55 |
+
- アプリケーション起動時にFastAPIサーバーが自動で起動
|
| 56 |
+
- セッション管理機能が完全に利用可能
|
| 57 |
+
- Cookie-based認証によるセキュアなセッション管理
|
| 58 |
+
|
| 59 |
+
#### 環境変数設定
|
| 60 |
+
Hugging Face Spacesの設定で以下の環境変数を設定してください:
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
TOGETHER_API_KEY=your_together_api_key_here
|
| 64 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
1. **リポジトリのインポート**
|
| 68 |
+
- このリポジトリをHugging Face Spacesにインポート
|
| 69 |
+
- または[デモサイト](https://huggingface.co/spaces/your-space-name)で直接体験
|
| 70 |
+
|
| 71 |
+
2. **Spaces設定**
|
| 72 |
+
- **SDK**: Streamlit
|
| 73 |
+
- **Python**: 3.10+
|
| 74 |
+
- **Hardware**: CPU Basic(2GB RAM)以上推奨
|
| 75 |
+
- **App File**: `spaces/main_app.py`
|
| 76 |
+
|
| 77 |
+
3. **必須環境変数**
|
| 78 |
+
```bash
|
| 79 |
+
TOGETHER_API_KEY=your_together_api_key_here
|
| 80 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
4. **オプション環境変数**
|
| 84 |
+
```bash
|
| 85 |
+
# 通常モード用カスタムプロンプト
|
| 86 |
+
SYSTEM_PROMPT_MARI=your_custom_prompt_here
|
| 87 |
+
|
| 88 |
+
# セーフティ解除モード用プロンプト
|
| 89 |
+
SYSTEM_PROMPT_URA=your_ura_mode_prompt_here
|
| 90 |
+
|
| 91 |
+
# デバッグモード有効化
|
| 92 |
+
DEBUG_MODE=true
|
| 93 |
+
|
| 94 |
+
# 手紙生成設定
|
| 95 |
+
MAX_DAILY_REQUESTS=5
|
| 96 |
+
BATCH_SCHEDULE_HOURS=2,3,4
|
| 97 |
+
ASYNC_LETTER_ENABLED=true
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
#### APIキーの取得方法
|
| 101 |
+
|
| 102 |
+
1. **Together AI**
|
| 103 |
+
- [Together AI](https://api.together.xyz/)にアクセス
|
| 104 |
+
- アカウント作成・ログイン
|
| 105 |
+
- APIキーを生成
|
| 106 |
+
|
| 107 |
+
2. **Groq**
|
| 108 |
+
- [Groq](https://console.groq.com/)にアクセス
|
| 109 |
+
- アカウント作成・ログイン
|
| 110 |
+
- APIキーを生成
|
| 111 |
+
|
| 112 |
+
3. **Hugging Face Spaces設定**
|
| 113 |
+
- Hugging Face Spacesの「Settings」→「Variables and secrets」で設定
|
| 114 |
+
|
| 115 |
+
### ローカル環境での実行
|
| 116 |
+
|
| 117 |
+
1. **リポジトリをクローン**
|
| 118 |
+
```bash
|
| 119 |
+
git clone <repository-url>
|
| 120 |
+
cd mari-chat-app
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
2. **依存関係をインストール**
|
| 124 |
+
```bash
|
| 125 |
+
pip install -r requirements.txt
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
3. **環境変数を設定**
|
| 129 |
+
`.env`ファイルを作成:
|
| 130 |
+
```bash
|
| 131 |
+
# 必須設定
|
| 132 |
+
TOGETHER_API_KEY=your_together_api_key_here
|
| 133 |
+
GROQ_API_KEY=your_groq_api_key_here
|
| 134 |
+
|
| 135 |
+
# オプション設定
|
| 136 |
+
SYSTEM_PROMPT_MARI=your_custom_prompt_here
|
| 137 |
+
SYSTEM_PROMPT_URA=your_ura_mode_prompt_here
|
| 138 |
+
DEBUG_MODE=true
|
| 139 |
+
|
| 140 |
+
# 手紙機能設定
|
| 141 |
+
MAX_DAILY_REQUESTS=5
|
| 142 |
+
STORAGE_PATH=/tmp/letters.json
|
| 143 |
+
BATCH_SCHEDULE_HOURS=2,3,4
|
| 144 |
+
ASYNC_LETTER_ENABLED=true
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
4. **アプリケーションを実行**
|
| 148 |
+
```bash
|
| 149 |
+
streamlit run main_app.py
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
5. **ブラウザでアクセス**
|
| 153 |
+
- 自動的にブラウザが開きます
|
| 154 |
+
- 手動の場合: `http://localhost:8501`
|
| 155 |
+
|
| 156 |
+
### Dockerでの実行
|
| 157 |
+
|
| 158 |
+
1. **Dockerイメージをビルド**
|
| 159 |
+
```bash
|
| 160 |
+
docker build -t mari-chat-app .
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
2. **コンテナを実行**
|
| 164 |
+
```bash
|
| 165 |
+
docker run -p 8501:8501 \
|
| 166 |
+
-e TOGETHER_API_KEY=your_api_key_here \
|
| 167 |
+
-e DEBUG_MODE=true \
|
| 168 |
+
mari-chat-app
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
3. **docker-composeを使用**
|
| 172 |
+
```bash
|
| 173 |
+
docker-compose up -d
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
4. **環境変数ファイルを使用**
|
| 177 |
+
```bash
|
| 178 |
+
docker run -p 8501:8501 --env-file spaces/.env mari-chat-app
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
## 📖 使用方法
|
| 182 |
+
|
| 183 |
+
### 🎭 チャット機能
|
| 184 |
+
|
| 185 |
+
#### 基本的な使い方
|
| 186 |
+
1. **メッセージ入力**: 画面下部のテキスト入力欄にメッセージを入力
|
| 187 |
+
2. **送信**: 送信ボタンをクリック
|
| 188 |
+
3. **応答確認**: 麻理からの応答が表示されます
|
| 189 |
+
4. **本音表示**: 画面右下のポチ(🐕)をクリックして本音を確認
|
| 190 |
+
|
| 191 |
+
#### ポチ機能の使い方
|
| 192 |
+
- **🐕 ポチ**: 画面右下に固定配置された犬のアシスタント
|
| 193 |
+
- **ワンクリック**: ポチをクリックすると全メッセージの本音が一括表示
|
| 194 |
+
- **吹き出し**: 「ポチは麻理の本音を察知したようだ・・・」の可愛い吹き出し(ポチボタンの上に左寄せ配置)
|
| 195 |
+
- **背景色変化**: 本音表示時は暖色系の背景に変化
|
| 196 |
+
- **レスポンシブ**: 画面サイズに応じて適切にサイズ調整(デスクトップ・タブレット・モバイル対応)
|
| 197 |
+
|
| 198 |
+
#### セーフティ機能
|
| 199 |
+
- **🔒/🔓ボタン**: 左サイドバー最上部のセーフティ切り替えボタン
|
| 200 |
+
- **色分け表示**: 有効時は緑色、解除時は赤色で一目で分かる
|
| 201 |
+
- **URAプロンプト**: `SYSTEM_PROMPT_URA`環境変数で独自のセーフティ解除モードプロンプトを設定可能
|
| 202 |
+
- **表現変化**: より大胆で直接的な表現が有効になる
|
| 203 |
+
- **本音への影響**: セーフティ解除時はより踏み込んだ感情表現
|
| 204 |
+
|
| 205 |
+
### ✉️ 手紙機能
|
| 206 |
+
|
| 207 |
+
#### 手紙のリクエスト
|
| 208 |
+
1. **「手紙を受け取る」タブ**を選択
|
| 209 |
+
2. **テーマ選択**: 日常、恋愛、励まし、感謝、お疲れ様、おやすみから選択
|
| 210 |
+
3. **配信時間設定**: 希望する受け取り時間を指定
|
| 211 |
+
4. **リクエスト送信**: 「手紙をリクエスト」ボタンをクリック
|
| 212 |
+
|
| 213 |
+
#### 手紙の受け取り
|
| 214 |
+
- **自動生成**: 指定時間にバックグラウンドで生成
|
| 215 |
+
- **通知**: 新しい手紙が届くとチャットで通知
|
| 216 |
+
- **履歴確認**: 過去の手紙履歴を確認可能
|
| 217 |
+
|
| 218 |
+
### 🎛️ サイドバー機能
|
| 219 |
+
|
| 220 |
+
#### セーフティ機能(最上部)
|
| 221 |
+
- **🔒/🔓ボタン**: セーフティの有効/無効を色で表示
|
| 222 |
+
- **緑色**: セーフティ有効(通常モード、`SYSTEM_PROMPT_MARI`使用)
|
| 223 |
+
- **赤色**: セーフティ解除(URAモード、`SYSTEM_PROMPT_URA`使用)
|
| 224 |
+
|
| 225 |
+
#### ステータス表示
|
| 226 |
+
- **好感度**: 白色の文字で表示される好感度(0-100)と左寒色→右暖色のグラデーションバー
|
| 227 |
+
- **関係性**: 敵対→中立→好意→親密→最接近の段階表示(ステージ番号なし)
|
| 228 |
+
- **現在のシーン**: 背景シーンの名前
|
| 229 |
+
|
| 230 |
+
#### 設定機能
|
| 231 |
+
- **🔄会話をリセット**: 大きな文字で表示される会話リセットボタン
|
| 232 |
+
- **🛠️デバッグ情報**: 詳細なシステム状態を表示(DEBUG_MODE=true時)
|
| 233 |
+
|
| 234 |
+
### 🎨 高度な機能
|
| 235 |
+
|
| 236 |
+
#### 自動シーン変更
|
| 237 |
+
- **Groq API検出**:最新推論モデルによる高精度な会話内容解析
|
| 238 |
+
- **フォールバック機能**: API失敗時はキーワードベースの検出に自動切り替え
|
| 239 |
+
- **キーワード検出**: 「美術館」「カフェ」「神社」「夜」などの場所・時間を話題にすると自動変化
|
| 240 |
+
- **背景切り替え**: 会話内容に応じて背景画像が動的に変化(常時更新、スキップ機能なし)
|
| 241 |
+
- **雰囲気演出**: シーンに合わせた色調とエフェクト
|
| 242 |
+
|
| 243 |
+
#### メモリ管理
|
| 244 |
+
- **長期記憶**: 重要な会話内容を自動的に記憶
|
| 245 |
+
- **要約機能**: 長い会話履歴を効率的に圧縮
|
| 246 |
+
- **特別な記憶**: 印象的な出来事を特別に保存
|
| 247 |
+
|
| 248 |
+
#### セッション分離
|
| 249 |
+
- **ユーザー独立**: 各ユーザーの会話は完全に分離
|
| 250 |
+
- **データ保護**: 他のユーザーの操作による影響なし
|
| 251 |
+
- **整合性チェック**: セッション状態の自動検証と復旧
|
| 252 |
+
|
| 253 |
+
## 🎨 シーン一覧
|
| 254 |
+
|
| 255 |
+
| シーン名 | 説明 | 背景画像 | トリガーワード |
|
| 256 |
+
|---------|------|----------|---------------|
|
| 257 |
+
| `default` | デフォルトの部屋 | 温かみのある室内 | 部屋、家 |
|
| 258 |
+
| `room_night` | 夜の部屋 | 夜景が見える室内 | 夜、寝る |
|
| 259 |
+
| `beach_sunset` | 夕暮れのビーチ | 美しい夕日のビー�� | ビーチ、海、夕日 |
|
| 260 |
+
| `festival_night` | 夜のお祭り | 賑やかな夜祭り | お祭り、花火、屋台 |
|
| 261 |
+
| `shrine_day` | 昼間の神社 | 静寂な神社の境内 | 神社、お参り、鳥居 |
|
| 262 |
+
| `cafe_afternoon` | 午後のカフェ | 落ち着いたカフェ | カフェ、コーヒー |
|
| 263 |
+
| `art_museum_night` | 夜の美術館 | 幻想的な美術館 | 美術館、アート、絵画 |
|
| 264 |
+
|
| 265 |
+
### シーン変更の仕組み
|
| 266 |
+
- **自動検出**: 会話内容から場所に関するキーワードを検出
|
| 267 |
+
- **スムーズ遷移**: 1.5秒のフェードイン・アウト効果
|
| 268 |
+
- **文脈適応**: 会話の流れに自然に溶け込む背景変化
|
| 269 |
+
- **美術館検出**: 美術館、アート、絵画、彫刻などのキーワードで夜の美術館シーンに変更
|
| 270 |
+
|
| 271 |
+
## 🔧 技術仕様
|
| 272 |
+
|
| 273 |
+
### API情報
|
| 274 |
+
- **プロバイダー**: [Together AI](https://api.together.xyz/) と [Groq](https://console.groq.com/)
|
| 275 |
+
- **使用モデル**:
|
| 276 |
+
- Together AI: `Qwen/Qwen3-235B-A22B-Instruct-2507-tput`(対話生成)
|
| 277 |
+
- Groq: `llama-3.1-70b-versatile`(シーン検出・手紙生成)
|
| 278 |
+
- **レート制限**: 適応的制限により安定した動作
|
| 279 |
+
- **フォールバック**: API障害時は固定応答で継続動作
|
| 280 |
+
- **シーン検出**: Groq API優先、失敗時はキーワードベース検出
|
| 281 |
+
|
| 282 |
+
### システム要件
|
| 283 |
+
- **Python**: 3.8以上(3.10推奨)
|
| 284 |
+
- **メモリ**: 最小1GB、推奨2GB以上
|
| 285 |
+
- **ストレージ**: 100MB以上の空き容量
|
| 286 |
+
- **ネットワーク**: インターネット接続必須
|
| 287 |
+
|
| 288 |
+
### パフォーマンス
|
| 289 |
+
- **応答時間**: 通常2-5秒
|
| 290 |
+
- **同時接続**: 複数ユーザー対応
|
| 291 |
+
- **メモリ効率**: 自動圧縮により長期間の安定動作
|
| 292 |
+
- **セッション管理**: 強化された分離機能
|
| 293 |
+
|
| 294 |
+
## 🔧 トラブルシューティング
|
| 295 |
+
|
| 296 |
+
### よくある問題と解決方法
|
| 297 |
+
|
| 298 |
+
#### 🔑 APIキー関連
|
| 299 |
+
**問題**: APIキーエラーが発生する
|
| 300 |
+
**解決方法**:
|
| 301 |
+
- `TOGETHER_API_KEY`が正しく設定されているか確認
|
| 302 |
+
- [Together AI](https://api.together.xyz/)でAPIキーの有効性を確認
|
| 303 |
+
- 環境変数の再設定後、アプリを再起動
|
| 304 |
+
|
| 305 |
+
#### 🚀 起動・動作問題
|
| 306 |
+
**問題**: アプリが起動しない
|
| 307 |
+
**解決方法**:
|
| 308 |
+
- 依存関係の再インストール: `pip install -r spaces/requirements.txt`
|
| 309 |
+
- Pythonバージョン確認: `python --version`(3.8以上必須)
|
| 310 |
+
- ポート8501が使用中でないか確認
|
| 311 |
+
|
| 312 |
+
**問題**: 応答が生成されない
|
| 313 |
+
**解決方法**:
|
| 314 |
+
- インターネット接続を確認
|
| 315 |
+
- APIの利用制限状況を確認
|
| 316 |
+
- デバッグモードでエラーログを確認
|
| 317 |
+
|
| 318 |
+
#### 🐕 ポチ機能問題
|
| 319 |
+
**問題**: ポチが表示されない、または動作しない
|
| 320 |
+
**解決方法**:
|
| 321 |
+
- ブラウザのキャッシュをクリア
|
| 322 |
+
- 画面右下にポチが固定表示されているか確認
|
| 323 |
+
- 会話をリセットして再試行
|
| 324 |
+
- デバッグモードでHIDDEN形式の検出状況を確認
|
| 325 |
+
|
| 326 |
+
#### 🎨 背景画像問題
|
| 327 |
+
**問題**: 背景画像が見えない、または白い背景になっている
|
| 328 |
+
**解決方法**:
|
| 329 |
+
- アプリケーションを再起動(背景更新スキップ機能は無効化済み)
|
| 330 |
+
- ブラウザのキャッシュをクリア
|
| 331 |
+
- 「美術館」「夜」「カフェ」などのキーワードでシーン変更をテスト
|
| 332 |
+
- デバッグモードで右上の「🖼️ 背景適用済」表示を確認
|
| 333 |
+
- タブやメッセージエリアは白い半透明背景で保護されているのが正常
|
| 334 |
+
|
| 335 |
+
#### 💾 メモリ・セッション問題
|
| 336 |
+
**問題**: メモリエラーや会話履歴の問題
|
| 337 |
+
**解決方法**:
|
| 338 |
+
- サイドバーの「🔄会話をリセット」を実行
|
| 339 |
+
- ブラウザのキャッシュとCookieをクリア
|
| 340 |
+
- アプリの再起動
|
| 341 |
+
|
| 342 |
+
### 🛠️ デバッグ機能
|
| 343 |
+
|
| 344 |
+
#### デバッグモードの有効化
|
| 345 |
+
1. 環境変数で`DEBUG_MODE=true`を設定
|
| 346 |
+
2. または、サイドバーの「🛠️デバッグ情報」を展開
|
| 347 |
+
|
| 348 |
+
#### 確認できる情報
|
| 349 |
+
- **セッション分離状態**: ユーザー独立性の確認
|
| 350 |
+
- **メモリ統計**: 使用量と圧縮状況
|
| 351 |
+
- **API応答ログ**: リクエスト・レスポンスの詳細
|
| 352 |
+
- **ポチ機能統計**: HIDDEN形式の検出状況
|
| 353 |
+
|
| 354 |
+
#### ログファイルの場所
|
| 355 |
+
- **Streamlitログ**: コンソール出力
|
| 356 |
+
- **アプリケーションログ**: Python loggingモジュール
|
| 357 |
+
- **セッション情報**: デバッグモードで表示
|
| 358 |
+
|
| 359 |
+
## 👨💻 開発者向け情報
|
| 360 |
+
|
| 361 |
+
### プロジェクト構造
|
| 362 |
+
|
| 363 |
+
```
|
| 364 |
+
MariChat2-AI--main/
|
| 365 |
+
├── main_app.py # 統合メインアプリケーション
|
| 366 |
+
├── components_chat_interface.py # チャット機能(ポチ機能付き)
|
| 367 |
+
├── components_dog_assistant.py # ポチ(犬)アシスタント
|
| 368 |
+
├── session_manager.py # セッション分離管理
|
| 369 |
+
├── core_dialogue.py # 対話生成(HIDDEN形式対応)
|
| 370 |
+
├── core_sentiment.py # 感情分析・好感度システム
|
| 371 |
+
├── core_scene_manager.py # 動的背景システム
|
| 372 |
+
├── core_memory_manager.py # 長期記憶管理
|
| 373 |
+
├── core_rate_limiter.py # レート制限
|
| 374 |
+
├── letter_*.py # 非同期手紙生成システム
|
| 375 |
+
├── async_*.py # 非同期処理関連
|
| 376 |
+
├── streamlit_styles.css # カスタムCSS(ポチ機能含む)
|
| 377 |
+
├── requirements.txt # 依存関係
|
| 378 |
+
├── requirements_session.txt # セッション管理用依存関係
|
| 379 |
+
├── .env # 環境変数設定
|
| 380 |
+
├── session_data/ # セッションデータ保存
|
| 381 |
+
├── tmp/ # 一時ファイル
|
| 382 |
+
├── push/ # プッシュ通知関連
|
| 383 |
+
├── Dockerfile # Docker設定
|
| 384 |
+
├── .dockerignore # Docker除外設定
|
| 385 |
+
└── README.md # このファイル
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
### 🐕 ポチ機能の実装
|
| 389 |
+
|
| 390 |
+
#### 核となる仕組み
|
| 391 |
+
```python
|
| 392 |
+
# HIDDEN形式の検出
|
| 393 |
+
pattern = r'\[HIDDEN:(.*?)\](.*)'
|
| 394 |
+
has_hidden, visible, hidden = detect_hidden_content(message)
|
| 395 |
+
|
| 396 |
+
# 画面右下への固定配置(左寄せ調整済み)
|
| 397 |
+
CSS: position: fixed; bottom: 20px; right: 20px; transform: translateX(-50px);
|
| 398 |
+
|
| 399 |
+
# 全メッセージの一括制御
|
| 400 |
+
st.session_state.show_all_hidden = not st.session_state.show_all_hidden
|
| 401 |
+
```
|
| 402 |
+
|
| 403 |
+
#### カスタマイズポイント
|
| 404 |
+
- **プロンプト**: `SYSTEM_PROMPT_MARI`/`SYSTEM_PROMPT_URA`で隠された真実の生成を制御
|
| 405 |
+
- **レスポンシブ**: CSSメディアクエリで画面サイズ対応(デスクトップ・タブレット・モバイル)
|
| 406 |
+
- **検出ロジック**: `_detect_hidden_content()`で形式を変更可能
|
| 407 |
+
- **ポチの見た目**: `components_dog_assistant.py`でデザイン調整
|
| 408 |
+
- **吹き出し位置**: 左寄せ配置でポチボタンの真上に表示
|
| 409 |
+
|
| 410 |
+
### ✉️ 手紙機能の実装
|
| 411 |
+
|
| 412 |
+
#### 非同期処理アーキテクチャ
|
| 413 |
+
```python
|
| 414 |
+
# バックグラウンド生成
|
| 415 |
+
async def generate_letter_async(theme, user_id):
|
| 416 |
+
# 非同期でAI生成
|
| 417 |
+
|
| 418 |
+
# スケジューラー
|
| 419 |
+
batch_scheduler.schedule_generation(user_id, theme, delivery_time)
|
| 420 |
+
|
| 421 |
+
# ストレージ管理
|
| 422 |
+
async_storage.save_letter(letter_data)
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
#### 手紙生成システム
|
| 426 |
+
- **非同期処理**: `async_letter_app.py`でバックグラウンド生成
|
| 427 |
+
- **設定管理**: `async_config_setup.py`で環境設定
|
| 428 |
+
- **レート制限**: `async_rate_limiter.py`でAPI使用量制御
|
| 429 |
+
- **ストレージ**: `async_storage_manager.py`でデータ永続化
|
| 430 |
+
|
| 431 |
+
### 🛡️ セッション分離システム
|
| 432 |
+
|
| 433 |
+
#### 強化された分離機能
|
| 434 |
+
```python
|
| 435 |
+
class SessionManager:
|
| 436 |
+
def __init__(self):
|
| 437 |
+
self.session_id = id(st.session_state)
|
| 438 |
+
self.user_id = None
|
| 439 |
+
|
| 440 |
+
def validate_session_integrity(self):
|
| 441 |
+
# セッション整合性チェック
|
| 442 |
+
```
|
| 443 |
+
|
| 444 |
+
### カスタマイズガイド
|
| 445 |
+
|
| 446 |
+
#### 新しいシーンの追加
|
| 447 |
+
1. `core_scene_manager.py`の`theme_urls`辞書に追加
|
| 448 |
+
2. 背景画像URLを設定
|
| 449 |
+
3. Groqプロンプトの`scenes_description`に説明を追加
|
| 450 |
+
4. フォールバック用の`keyword_scene_map`にトリガーワードを定義
|
| 451 |
+
|
| 452 |
+
#### プロンプトのカスタマイズ
|
| 453 |
+
```bash
|
| 454 |
+
# 通常モード
|
| 455 |
+
SYSTEM_PROMPT_MARI="あなたのカスタムプロンプト..."
|
| 456 |
+
|
| 457 |
+
# セーフティ解除モード
|
| 458 |
+
SYSTEM_PROMPT_URA="より大胆なプロンプト..."
|
| 459 |
+
```
|
| 460 |
+
|
| 461 |
+
#### UIスタイルの変更
|
| 462 |
+
- `streamlit_styles.css`でカラーテーマ、アニメーション、レイアウトを調整
|
| 463 |
+
- CSS変数を使用した統一的なデザインシステム
|
| 464 |
+
- ポチの固定配置とレスポンシブ対応
|
| 465 |
+
|
| 466 |
+
### テスト機能
|
| 467 |
+
|
| 468 |
+
#### 提供されるテストスクリプト
|
| 469 |
+
- `test_mask_functionality.py`: ポチ機能の動作確認
|
| 470 |
+
- `test_multiple_hidden_fixed.py`: 複数HIDDEN形式の処理確認
|
| 471 |
+
- `test_hidden_format_issue.py`: HIDDEN形式の問題診断
|
| 472 |
+
- `debug_session_state.py`: セッション状態のデバッグ
|
| 473 |
+
|
| 474 |
+
#### テスト実行方法
|
| 475 |
+
```bash
|
| 476 |
+
python test_mask_functionality.py
|
| 477 |
+
python test_multiple_hidden_fixed.py
|
| 478 |
+
```
|
| 479 |
+
|
| 480 |
+
## 🤝 貢献・開発参加
|
| 481 |
+
|
| 482 |
+
### バグ報告・機能提案
|
| 483 |
+
- **Issues**: GitHubのIssuesでバグ報告や機能提案をお願いします
|
| 484 |
+
- **Discussion**: 新機能のアイデアや改善案の議論
|
| 485 |
+
- **Pull Request**: コード改善やバグ修正のPRを歓迎します
|
| 486 |
+
|
| 487 |
+
### 開発ガイドライン
|
| 488 |
+
1. **コードスタイル**: PEP 8に準拠
|
| 489 |
+
2. **テスト**: 新機能には対応するテストを追加
|
| 490 |
+
3. **ドキュメント**: 変更内容をREADMEに反映
|
| 491 |
+
4. **コミット**: 明確で説明的なコミットメッセージ
|
| 492 |
+
|
| 493 |
+
### 開発環境のセットアップ
|
| 494 |
+
```bash
|
| 495 |
+
# 開発用依存関係のインストール
|
| 496 |
+
pip install -r requirements.txt
|
| 497 |
+
|
| 498 |
+
# テストの実行
|
| 499 |
+
python -m pytest tests/
|
| 500 |
+
|
| 501 |
+
# コードフォーマット
|
| 502 |
+
black .
|
| 503 |
+
flake8 .
|
| 504 |
+
```
|
| 505 |
+
|
| 506 |
+
## 📄 ライセンス
|
| 507 |
+
|
| 508 |
+
このプロジェクトは**MITライセンス**の下で公開されています。
|
| 509 |
+
|
| 510 |
+
## 🙏 謝辞
|
| 511 |
+
|
| 512 |
+
- **Together AI**: 高品質なLLM APIの提供
|
| 513 |
+
- **Streamlit**: 直感的なWebアプリフレームワーク
|
| 514 |
+
- **コミュニティ**: バグ報告や機能提案をいただいた皆様
|
| 515 |
+
|
| 516 |
+
## 📞 サポート・連絡先
|
| 517 |
+
|
| 518 |
+
- **GitHub Issues**: バグ報告・機能提案
|
| 519 |
+
- **Discussions**: 一般的な質問・議論
|
| 520 |
+
- **Email**: 重要な問題やセキュリティ関連
|
| 521 |
+
|
| 522 |
+
## 🔄 更新履歴
|
| 523 |
+
|
| 524 |
+
### v2.1.0 (最新)
|
| 525 |
+
- 🐕 **ポチ機能**: 画面右下の犬アシスタントによる本音一括表示(吹き出し位置を左寄せに調整)
|
| 526 |
+
- 🔓 **セーフティ統合**: 左サイドバーへの統合と色分け表示(URAプロンプト対応)
|
| 527 |
+
- 🎨 **シーン検出強化**: Groq API優先、フォールバック機能付きの高精度シーン変更
|
| 528 |
+
- 📊 **好感度バー改善**: 左寒色→右暖色のグラデーション、動的塗りつぶし表示
|
| 529 |
+
- 🎨 **背景システム改善**: スキップ機能無効化による確実な背景表示
|
| 530 |
+
- 📱 **レスポンシブ対応**: 様々な画面サイズに最適化
|
| 531 |
+
- ✉️ **非同期手紙生成**: バックグラウンド手紙生成
|
| 532 |
+
- 🛡️ **セッション分離強化**: マルチユーザー対応改善
|
| 533 |
+
|
| 534 |
+
### v2.0.0
|
| 535 |
+
- 🎭 **マスク機能**: 隠された真実の表示機能(現在はポチ機能に統合)
|
| 536 |
+
- 🔓 **セーフティ解除モード**: より大胆な表現モード
|
| 537 |
+
- ✉️ **非同期手紙生成**: バックグラウンド手紙生成
|
| 538 |
+
- 🛡️ **セッション分離強化**: マルチユーザー対応改善
|
| 539 |
+
- 🎨 **UI再設計**: モダンで直感的なインターフェース
|
| 540 |
+
|
| 541 |
+
### v1.0.0
|
| 542 |
+
- 基本的なチャット機能
|
| 543 |
+
- 好感度システム
|
| 544 |
+
- 動的背景変更
|
| 545 |
+
- メモリ管理機能
|
| 546 |
+
|
| 547 |
+
---
|
| 548 |
+
|
| 549 |
+
<div align="center">
|
| 550 |
+
|
| 551 |
+
**🐕 Made with ❤️ using Streamlit & Together AI 🐕**
|
| 552 |
+
|
| 553 |
+
*麻理とポチとの特別な時間をお楽しみください*
|
| 554 |
+
|
| 555 |
+
[](https://streamlit.io/)
|
| 556 |
+
[](https://together.ai/)
|
| 557 |
+
[](https://python.org/)
|
| 558 |
+
|
| 559 |
+
</div>
|
__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
非同期手紙生成アプリ(麻理AI)
|
| 3 |
+
Asynchronous Letter Generation App (Mari AI)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "1.0.0"
|
| 7 |
+
__author__ = "Mari AI Team"
|
app.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
メインアプリケーションモジュール
|
| 3 |
+
Main application module
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from letter_config import Config
|
| 10 |
+
from letter_logger import get_app_logger
|
| 11 |
+
|
| 12 |
+
# プロジェクトルートをPythonパスに追加
|
| 13 |
+
project_root = Path(__file__).parent.parent
|
| 14 |
+
sys.path.insert(0, str(project_root))
|
| 15 |
+
|
| 16 |
+
logger = get_app_logger()
|
| 17 |
+
|
| 18 |
+
class LetterApp:
|
| 19 |
+
"""非同期手紙生成アプリケーションのメインクラス"""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""アプリケーションを初期化"""
|
| 23 |
+
self.config = Config()
|
| 24 |
+
self.logger = logger
|
| 25 |
+
self._initialized = False
|
| 26 |
+
|
| 27 |
+
async def initialize(self) -> bool:
|
| 28 |
+
"""
|
| 29 |
+
アプリケーションを初期化する
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
初期化が成功したかどうか
|
| 33 |
+
"""
|
| 34 |
+
try:
|
| 35 |
+
self.logger.info("アプリケーションを初期化中...")
|
| 36 |
+
|
| 37 |
+
# 設定の妥当性をチェック
|
| 38 |
+
if not self.config.validate_config():
|
| 39 |
+
self.logger.error("設定の検証に失敗しました")
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
# ストレージディレクトリを作成
|
| 43 |
+
await self._setup_storage_directories()
|
| 44 |
+
|
| 45 |
+
# ログディレクトリを作成
|
| 46 |
+
await self._setup_log_directories()
|
| 47 |
+
|
| 48 |
+
self._initialized = True
|
| 49 |
+
self.logger.info("アプリケーションの初期化が完了しました")
|
| 50 |
+
return True
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}")
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
async def _setup_storage_directories(self):
|
| 57 |
+
"""ストレージディレクトリを作成"""
|
| 58 |
+
storage_dir = Path(self.config.STORAGE_PATH).parent
|
| 59 |
+
backup_dir = Path(self.config.BACKUP_PATH)
|
| 60 |
+
|
| 61 |
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
|
| 64 |
+
self.logger.info(f"ストレージディレクトリを作成: {storage_dir}")
|
| 65 |
+
self.logger.info(f"バックアップディレクトリを作成: {backup_dir}")
|
| 66 |
+
|
| 67 |
+
async def _setup_log_directories(self):
|
| 68 |
+
"""ログディレクトリを作成"""
|
| 69 |
+
if not self.config.DEBUG_MODE:
|
| 70 |
+
log_dir = Path("/mnt/data/logs")
|
| 71 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
self.logger.info(f"ログディレクトリを作成: {log_dir}")
|
| 73 |
+
|
| 74 |
+
def is_initialized(self) -> bool:
|
| 75 |
+
"""アプリケーションが初期化されているかチェック"""
|
| 76 |
+
return self._initialized
|
| 77 |
+
|
| 78 |
+
def get_config(self) -> Config:
|
| 79 |
+
"""設定オブジェクトを取得"""
|
| 80 |
+
return self.config
|
| 81 |
+
|
| 82 |
+
# グローバルアプリケーションインスタンス
|
| 83 |
+
app_instance = None
|
| 84 |
+
|
| 85 |
+
async def get_app() -> LetterApp:
|
| 86 |
+
"""アプリケーションインスタンスを取得(シングルトン)"""
|
| 87 |
+
global app_instance
|
| 88 |
+
|
| 89 |
+
if app_instance is None:
|
| 90 |
+
app_instance = LetterApp()
|
| 91 |
+
await app_instance.initialize()
|
| 92 |
+
|
| 93 |
+
return app_instance
|
| 94 |
+
|
| 95 |
+
def run_app():
|
| 96 |
+
"""アプリケーションを実行"""
|
| 97 |
+
async def main():
|
| 98 |
+
app = await get_app()
|
| 99 |
+
if app.is_initialized():
|
| 100 |
+
logger.info("アプリケーションが正常に起動しました")
|
| 101 |
+
else:
|
| 102 |
+
logger.error("アプリケーションの起動に失敗しました")
|
| 103 |
+
sys.exit(1)
|
| 104 |
+
|
| 105 |
+
asyncio.run(main())
|
| 106 |
+
|
| 107 |
+
if __name__ == "__main__":
|
| 108 |
+
run_app()
|
async_config_setup.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
非同期手紙生成システムの設定初期化
|
| 3 |
+
Async Letter Generation System Configuration Setup
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from letter_config import Config
|
| 10 |
+
from letter_logger import get_app_logger
|
| 11 |
+
|
| 12 |
+
logger = get_app_logger()
|
| 13 |
+
|
| 14 |
+
def initialize_config() -> bool:
|
| 15 |
+
"""
|
| 16 |
+
設定を初期化し、必要なディレクトリを作成する
|
| 17 |
+
|
| 18 |
+
Returns:
|
| 19 |
+
初期化が成功したかどうか
|
| 20 |
+
"""
|
| 21 |
+
try:
|
| 22 |
+
# 設定の妥当性をチェック
|
| 23 |
+
if not Config.validate_config():
|
| 24 |
+
logger.error("設定の検証に失敗しました")
|
| 25 |
+
return False
|
| 26 |
+
|
| 27 |
+
# 必要なディレクトリを作成
|
| 28 |
+
storage_path = Path(Config.STORAGE_PATH)
|
| 29 |
+
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
backup_path = Path(Config.BACKUP_PATH)
|
| 32 |
+
backup_path.mkdir(parents=True, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
# ログディレクトリを作成(本番環境用)
|
| 35 |
+
if not Config.DEBUG_MODE:
|
| 36 |
+
log_dir = Path("/tmp")
|
| 37 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
|
| 39 |
+
logger.info("設定初期化が完了しました")
|
| 40 |
+
logger.info(f"ストレージパス: {Config.STORAGE_PATH}")
|
| 41 |
+
logger.info(f"バックアップパス: {Config.BACKUP_PATH}")
|
| 42 |
+
logger.info(f"デバッグモード: {Config.DEBUG_MODE}")
|
| 43 |
+
logger.info(f"バッチ処理時刻: {Config.BATCH_SCHEDULE_HOURS}")
|
| 44 |
+
|
| 45 |
+
return True
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"設定初期化エラー: {e}")
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
def check_api_keys() -> bool:
|
| 52 |
+
"""
|
| 53 |
+
API キーの存在をチェックする
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
API キーが設定されているかどうか
|
| 57 |
+
"""
|
| 58 |
+
try:
|
| 59 |
+
missing_keys = []
|
| 60 |
+
|
| 61 |
+
if not Config.GROQ_API_KEY:
|
| 62 |
+
missing_keys.append("GROQ_API_KEY")
|
| 63 |
+
|
| 64 |
+
if not Config.GEMINI_API_KEY:
|
| 65 |
+
missing_keys.append("GEMINI_API_KEY")
|
| 66 |
+
|
| 67 |
+
if missing_keys:
|
| 68 |
+
logger.error(f"必要なAPI キーが設定されていません: {', '.join(missing_keys)}")
|
| 69 |
+
return False
|
| 70 |
+
|
| 71 |
+
logger.info("API キーの確認が完了しました")
|
| 72 |
+
return True
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
logger.error(f"API キー確認エラー: {e}")
|
| 76 |
+
return False
|
| 77 |
+
|
| 78 |
+
def setup_environment() -> bool:
|
| 79 |
+
"""
|
| 80 |
+
環境をセットアップする
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
セットアップが成功したかどうか
|
| 84 |
+
"""
|
| 85 |
+
try:
|
| 86 |
+
# 設定を初期化
|
| 87 |
+
if not initialize_config():
|
| 88 |
+
return False
|
| 89 |
+
|
| 90 |
+
# API キーをチェック
|
| 91 |
+
if not check_api_keys():
|
| 92 |
+
return False
|
| 93 |
+
|
| 94 |
+
# Hugging Face Spaces 固有の設定
|
| 95 |
+
if os.getenv("SPACE_ID"):
|
| 96 |
+
logger.info(f"Hugging Face Spaces環境で実行中: {os.getenv('SPACE_ID')}")
|
| 97 |
+
|
| 98 |
+
# Spaces用の追加設定があればここに記述
|
| 99 |
+
|
| 100 |
+
logger.info("環境セットアップが完了しました")
|
| 101 |
+
return True
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"環境セットアップエラー: {e}")
|
| 105 |
+
return False
|
| 106 |
+
|
| 107 |
+
def get_system_info() -> dict:
|
| 108 |
+
"""
|
| 109 |
+
システム情報を取得する
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
システム情報の辞書
|
| 113 |
+
"""
|
| 114 |
+
return {
|
| 115 |
+
"python_version": sys.version,
|
| 116 |
+
"storage_path": Config.STORAGE_PATH,
|
| 117 |
+
"backup_path": Config.BACKUP_PATH,
|
| 118 |
+
"debug_mode": Config.DEBUG_MODE,
|
| 119 |
+
"batch_hours": Config.BATCH_SCHEDULE_HOURS,
|
| 120 |
+
"max_daily_requests": Config.MAX_DAILY_REQUESTS,
|
| 121 |
+
"generation_timeout": Config.GENERATION_TIMEOUT,
|
| 122 |
+
"max_concurrent_generations": Config.MAX_CONCURRENT_GENERATIONS,
|
| 123 |
+
"groq_model": Config.GROQ_MODEL,
|
| 124 |
+
"Together_model": Config.TOGETHER_API_MODEL,
|
| 125 |
+
"available_themes": Config.AVAILABLE_THEMES,
|
| 126 |
+
"space_id": os.getenv("SPACE_ID", "local"),
|
| 127 |
+
"streamlit_port": Config.STREAMLIT_PORT
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
# スクリプトとして実行された場合の設定確認
|
| 132 |
+
print("非同期手紙生成システム設定確認")
|
| 133 |
+
print("=" * 50)
|
| 134 |
+
|
| 135 |
+
if setup_environment():
|
| 136 |
+
print("✅ 環境セットアップ成功")
|
| 137 |
+
|
| 138 |
+
system_info = get_system_info()
|
| 139 |
+
for key, value in system_info.items():
|
| 140 |
+
print(f"{key}: {value}")
|
| 141 |
+
else:
|
| 142 |
+
print("❌ 環境セットアップ失敗")
|
| 143 |
+
sys.exit(1)
|
async_letter_app.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
メインアプリケーションモジュール
|
| 3 |
+
Main application module
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
# このファイルが依存している他のモジュールをインポート
|
| 11 |
+
from letter_config import Config
|
| 12 |
+
from letter_logger import get_app_logger
|
| 13 |
+
|
| 14 |
+
# このモジュール用のロガーを取得
|
| 15 |
+
logger = get_app_logger()
|
| 16 |
+
|
| 17 |
+
class LetterApp:
|
| 18 |
+
"""非同期手紙生成アプリケーションのメインクラス"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
"""アプリケーションを初期化"""
|
| 22 |
+
self.config = Config()
|
| 23 |
+
self.logger = logger
|
| 24 |
+
self._initialized = False
|
| 25 |
+
|
| 26 |
+
async def initialize(self) -> bool:
|
| 27 |
+
"""
|
| 28 |
+
アプリケーションを初期化する
|
| 29 |
+
|
| 30 |
+
Returns:
|
| 31 |
+
初期化が成功したかどうか
|
| 32 |
+
"""
|
| 33 |
+
try:
|
| 34 |
+
self.logger.info("アプリケーションを初期化中...")
|
| 35 |
+
|
| 36 |
+
# 設定の妥当性をチェック
|
| 37 |
+
if not self.config.validate_config():
|
| 38 |
+
self.logger.error("設定の検証に失敗しました")
|
| 39 |
+
return False
|
| 40 |
+
|
| 41 |
+
# ストレージディレクトリを作成
|
| 42 |
+
await self._setup_storage_directories()
|
| 43 |
+
|
| 44 |
+
# ログディレクトリを作成
|
| 45 |
+
await self._setup_log_directories()
|
| 46 |
+
|
| 47 |
+
self._initialized = True
|
| 48 |
+
self.logger.info("アプリケーションの初期化が完了しました")
|
| 49 |
+
return True
|
| 50 |
+
|
| 51 |
+
except Exception as e:
|
| 52 |
+
self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}")
|
| 53 |
+
return False
|
| 54 |
+
|
| 55 |
+
async def _setup_storage_directories(self):
|
| 56 |
+
"""ストレージディレクトリを作成"""
|
| 57 |
+
storage_dir = Path(self.config.STORAGE_PATH).parent
|
| 58 |
+
backup_dir = Path(self.config.BACKUP_PATH)
|
| 59 |
+
|
| 60 |
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
| 62 |
+
|
| 63 |
+
self.logger.info(f"ストレージディレクトリを作成: {storage_dir}")
|
| 64 |
+
self.logger.info(f"バックアップディレクトリを作成: {backup_dir}")
|
| 65 |
+
|
| 66 |
+
async def _setup_log_directories(self):
|
| 67 |
+
"""ログディレクトリを作成"""
|
| 68 |
+
if not self.config.DEBUG_MODE:
|
| 69 |
+
# ログファイルは個別のロガーで設定されるため、
|
| 70 |
+
# ここでは共通の親ディレクトリを作成する例
|
| 71 |
+
log_dir = Path("/tmp/logs") # 例: /tmp/batch.log などの親
|
| 72 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
self.logger.info(f"ログディレクトリを作成: {log_dir}")
|
| 74 |
+
|
| 75 |
+
def is_initialized(self) -> bool:
|
| 76 |
+
"""アプリケーションが初期化されているかチェック"""
|
| 77 |
+
return self._initialized
|
| 78 |
+
|
| 79 |
+
def get_config(self) -> Config:
|
| 80 |
+
"""設定オブジェクトを取得"""
|
| 81 |
+
return self.config
|
| 82 |
+
|
| 83 |
+
# グローバルアプリケーションインスタンス(シングルトンパターン)
|
| 84 |
+
app_instance = None
|
| 85 |
+
|
| 86 |
+
async def get_app() -> LetterApp:
|
| 87 |
+
"""アプリケーションインスタンスを取得(シングルトン)"""
|
| 88 |
+
global app_instance
|
| 89 |
+
|
| 90 |
+
if app_instance is None:
|
| 91 |
+
app_instance = LetterApp()
|
| 92 |
+
await app_instance.initialize()
|
| 93 |
+
|
| 94 |
+
return app_instance
|
| 95 |
+
|
| 96 |
+
def run_app():
|
| 97 |
+
"""アプリケーションを実行(テスト用)"""
|
| 98 |
+
async def main():
|
| 99 |
+
app = await get_app()
|
| 100 |
+
if app.is_initialized():
|
| 101 |
+
logger.info("アプリケーションが正常に起動しました")
|
| 102 |
+
else:
|
| 103 |
+
logger.error("アプリケーションの起動に失敗しました")
|
| 104 |
+
sys.exit(1)
|
| 105 |
+
|
| 106 |
+
asyncio.run(main())
|
| 107 |
+
|
| 108 |
+
# このファイルが直接実行された場合にテスト用の起動処理を行う
|
| 109 |
+
if __name__ == "__main__":
|
| 110 |
+
run_app()
|
async_rate_limiter.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
非同期レート制限管理クラス
|
| 3 |
+
1日1回制限とAPI呼び出し制限を管理し、
|
| 4 |
+
デバッグモード時の制限緩和機能を提供します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Dict, Any, Optional, Tuple
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
# ログ設定
|
| 14 |
+
logging.basicConfig(level=logging.INFO)
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class RateLimitError(Exception):
|
| 19 |
+
"""レート制限エラー"""
|
| 20 |
+
pass
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AsyncRateLimitManager:
|
| 24 |
+
"""非同期レート制限管理クラス"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, storage_manager, max_requests: int = 1):
|
| 27 |
+
self.storage = storage_manager
|
| 28 |
+
|
| 29 |
+
# 設定値(環境変数から取得、デフォルト値あり)
|
| 30 |
+
self.max_daily_requests = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
|
| 31 |
+
self.max_api_calls_per_day = int(os.getenv("MAX_API_CALLS_PER_DAY", "10"))
|
| 32 |
+
self.debug_mode = os.getenv("DEBUG_MODE", "false").lower() == "true"
|
| 33 |
+
|
| 34 |
+
# デバッグモード時の制限緩和
|
| 35 |
+
if self.debug_mode:
|
| 36 |
+
self.max_daily_requests = int(os.getenv("DEBUG_MAX_DAILY_REQUESTS", "10"))
|
| 37 |
+
self.max_api_calls_per_day = int(os.getenv("DEBUG_MAX_API_CALLS", "100"))
|
| 38 |
+
logger.info("デバッグモードが有効です。制限が緩和されています")
|
| 39 |
+
|
| 40 |
+
logger.info(f"レート制限設定 - 1日のリクエスト上限: {self.max_daily_requests}, API呼び出し上限: {self.max_api_calls_per_day}")
|
| 41 |
+
|
| 42 |
+
async def check_daily_request_limit(self, user_id: str) -> Tuple[bool, Dict[str, Any]]:
|
| 43 |
+
"""1日のリクエスト制限をチェック"""
|
| 44 |
+
try:
|
| 45 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 46 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 47 |
+
|
| 48 |
+
# 今日のリクエスト数を取得
|
| 49 |
+
daily_requests = user_data["rate_limits"]["daily_requests"]
|
| 50 |
+
today_requests = daily_requests.get(today, 0)
|
| 51 |
+
|
| 52 |
+
# 制限チェック
|
| 53 |
+
is_allowed = today_requests < self.max_daily_requests
|
| 54 |
+
|
| 55 |
+
limit_info = {
|
| 56 |
+
"today_requests": today_requests,
|
| 57 |
+
"max_requests": self.max_daily_requests,
|
| 58 |
+
"remaining": max(0, self.max_daily_requests - today_requests),
|
| 59 |
+
"reset_time": self._get_next_reset_time(),
|
| 60 |
+
"debug_mode": self.debug_mode
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
if not is_allowed:
|
| 64 |
+
logger.warning(f"ユーザー {user_id} の1日のリクエスト制限に達しました ({today_requests}/{self.max_daily_requests})")
|
| 65 |
+
|
| 66 |
+
return is_allowed, limit_info
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
logger.error(f"1日のリクエスト制限チェックエラー: {e}")
|
| 70 |
+
# エラー時は制限を適用
|
| 71 |
+
return False, {"error": str(e)}
|
| 72 |
+
|
| 73 |
+
async def check_api_call_limit(self, user_id: str) -> Tuple[bool, Dict[str, Any]]:
|
| 74 |
+
"""API呼び出し制限をチェック"""
|
| 75 |
+
try:
|
| 76 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 77 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 78 |
+
|
| 79 |
+
# 今日のAPI呼び出し数を取得
|
| 80 |
+
api_calls = user_data["rate_limits"]["api_calls"]
|
| 81 |
+
today_calls = api_calls.get(today, 0)
|
| 82 |
+
|
| 83 |
+
# 制限チェック
|
| 84 |
+
is_allowed = today_calls < self.max_api_calls_per_day
|
| 85 |
+
|
| 86 |
+
limit_info = {
|
| 87 |
+
"today_calls": today_calls,
|
| 88 |
+
"max_calls": self.max_api_calls_per_day,
|
| 89 |
+
"remaining": max(0, self.max_api_calls_per_day - today_calls),
|
| 90 |
+
"reset_time": self._get_next_reset_time(),
|
| 91 |
+
"debug_mode": self.debug_mode
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
if not is_allowed:
|
| 95 |
+
logger.warning(f"ユーザー {user_id} のAPI呼び出し制限に達しました ({today_calls}/{self.max_api_calls_per_day})")
|
| 96 |
+
|
| 97 |
+
return is_allowed, limit_info
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"API呼び出し制限チェックエラー: {e}")
|
| 101 |
+
# エラー時は制限を適用
|
| 102 |
+
return False, {"error": str(e)}
|
| 103 |
+
|
| 104 |
+
async def record_request(self, user_id: str) -> None:
|
| 105 |
+
"""リクエストを記録"""
|
| 106 |
+
try:
|
| 107 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 108 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 109 |
+
|
| 110 |
+
# 今日のリクエスト数を増加
|
| 111 |
+
if "daily_requests" not in user_data["rate_limits"]:
|
| 112 |
+
user_data["rate_limits"]["daily_requests"] = {}
|
| 113 |
+
|
| 114 |
+
user_data["rate_limits"]["daily_requests"][today] = \
|
| 115 |
+
user_data["rate_limits"]["daily_requests"].get(today, 0) + 1
|
| 116 |
+
|
| 117 |
+
# プロファイルの最終リクエスト日を更新
|
| 118 |
+
user_data["profile"]["last_request"] = today
|
| 119 |
+
|
| 120 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 121 |
+
|
| 122 |
+
logger.info(f"ユーザー {user_id} のリクエストを記録しました")
|
| 123 |
+
|
| 124 |
+
except Exception as e:
|
| 125 |
+
logger.error(f"リクエスト記録エラー: {e}")
|
| 126 |
+
raise RateLimitError(f"リクエストの記録に失敗しました: {e}")
|
| 127 |
+
|
| 128 |
+
async def record_api_call(self, user_id: str, api_type: str = "general") -> None:
|
| 129 |
+
"""API呼び出しを記録"""
|
| 130 |
+
try:
|
| 131 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 132 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 133 |
+
|
| 134 |
+
# 今日のAPI呼び出し数を増加
|
| 135 |
+
if "api_calls" not in user_data["rate_limits"]:
|
| 136 |
+
user_data["rate_limits"]["api_calls"] = {}
|
| 137 |
+
|
| 138 |
+
user_data["rate_limits"]["api_calls"][today] = \
|
| 139 |
+
user_data["rate_limits"]["api_calls"].get(today, 0) + 1
|
| 140 |
+
|
| 141 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 142 |
+
|
| 143 |
+
logger.info(f"ユーザー {user_id} のAPI呼び出し ({api_type}) を記録しました")
|
| 144 |
+
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"API呼び出し記録エラー: {e}")
|
| 147 |
+
raise RateLimitError(f"API呼び出しの記録に失敗しました: {e}")
|
| 148 |
+
|
| 149 |
+
async def get_user_limits_status(self, user_id: str) -> Dict[str, Any]:
|
| 150 |
+
"""ユーザーの制限状況を取得"""
|
| 151 |
+
try:
|
| 152 |
+
# リクエスト制限の確認
|
| 153 |
+
request_allowed, request_info = await self.check_daily_request_limit(user_id)
|
| 154 |
+
|
| 155 |
+
# API呼び出し制限の確認
|
| 156 |
+
api_allowed, api_info = await self.check_api_call_limit(user_id)
|
| 157 |
+
|
| 158 |
+
# 次回リクエスト可能時刻の計算
|
| 159 |
+
next_request_time = None
|
| 160 |
+
if not request_allowed:
|
| 161 |
+
next_request_time = self._get_next_reset_time()
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"request_limit": {
|
| 165 |
+
"allowed": request_allowed,
|
| 166 |
+
"info": request_info
|
| 167 |
+
},
|
| 168 |
+
"api_limit": {
|
| 169 |
+
"allowed": api_allowed,
|
| 170 |
+
"info": api_info
|
| 171 |
+
},
|
| 172 |
+
"next_request_time": next_request_time,
|
| 173 |
+
"debug_mode": self.debug_mode
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"制限状況取得エラー: {e}")
|
| 178 |
+
return {"error": str(e)}
|
| 179 |
+
|
| 180 |
+
async def reset_daily_counters(self) -> int:
|
| 181 |
+
"""1日のカウンターをリセット(古いデータを削除)"""
|
| 182 |
+
try:
|
| 183 |
+
# 7日以上前のデータを削除
|
| 184 |
+
cutoff_date = datetime.now() - timedelta(days=7)
|
| 185 |
+
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
|
| 186 |
+
|
| 187 |
+
all_users = await self.storage.get_all_users()
|
| 188 |
+
reset_count = 0
|
| 189 |
+
|
| 190 |
+
for user_id in all_users:
|
| 191 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 192 |
+
|
| 193 |
+
# 古い1日のリクエストデータを削除
|
| 194 |
+
daily_requests = user_data["rate_limits"]["daily_requests"]
|
| 195 |
+
dates_to_delete = [date for date in daily_requests.keys() if date < cutoff_str]
|
| 196 |
+
|
| 197 |
+
for date in dates_to_delete:
|
| 198 |
+
del daily_requests[date]
|
| 199 |
+
reset_count += 1
|
| 200 |
+
|
| 201 |
+
# 古いAPI呼び出しデータを削除
|
| 202 |
+
api_calls = user_data["rate_limits"]["api_calls"]
|
| 203 |
+
dates_to_delete = [date for date in api_calls.keys() if date < cutoff_str]
|
| 204 |
+
|
| 205 |
+
for date in dates_to_delete:
|
| 206 |
+
del api_calls[date]
|
| 207 |
+
reset_count += 1
|
| 208 |
+
|
| 209 |
+
if reset_count > 0:
|
| 210 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 211 |
+
|
| 212 |
+
if reset_count > 0:
|
| 213 |
+
logger.info(f"{reset_count}件の古い制限データをリセットしました")
|
| 214 |
+
|
| 215 |
+
return reset_count
|
| 216 |
+
|
| 217 |
+
except Exception as e:
|
| 218 |
+
logger.error(f"カウンターリセットエラー: {e}")
|
| 219 |
+
return 0
|
| 220 |
+
|
| 221 |
+
def _get_next_reset_time(self) -> str:
|
| 222 |
+
"""次のリセット時刻を取得(翌日の0時)"""
|
| 223 |
+
tomorrow = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
| 224 |
+
return tomorrow.isoformat()
|
| 225 |
+
|
| 226 |
+
async def is_request_allowed(self, user_id: str) -> Tuple[bool, str]:
|
| 227 |
+
"""リクエストが許可されているかチェック(統合チェック)"""
|
| 228 |
+
try:
|
| 229 |
+
# 1日のリクエスト制限チェック
|
| 230 |
+
request_allowed, request_info = await self.check_daily_request_limit(user_id)
|
| 231 |
+
|
| 232 |
+
if not request_allowed:
|
| 233 |
+
remaining_time = self._calculate_remaining_time()
|
| 234 |
+
return False, f"1日のリクエスト制限に達しています。次回リクエスト可能時刻: {remaining_time}"
|
| 235 |
+
|
| 236 |
+
# API呼び出し制限チェック
|
| 237 |
+
api_allowed, api_info = await self.check_api_call_limit(user_id)
|
| 238 |
+
|
| 239 |
+
if not api_allowed:
|
| 240 |
+
remaining_time = self._calculate_remaining_time()
|
| 241 |
+
return False, f"API呼び出し制限に達しています。次回リクエスト可能時刻: {remaining_time}"
|
| 242 |
+
|
| 243 |
+
return True, "リクエスト可能です"
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"リクエスト許可チェックエラー: {e}")
|
| 247 |
+
return False, f"制限チェック中にエラーが発生しました: {e}"
|
| 248 |
+
|
| 249 |
+
def _calculate_remaining_time(self) -> str:
|
| 250 |
+
"""次回リクエスト可能までの残り時間を計算"""
|
| 251 |
+
now = datetime.now()
|
| 252 |
+
tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
| 253 |
+
remaining = tomorrow - now
|
| 254 |
+
|
| 255 |
+
hours = remaining.seconds // 3600
|
| 256 |
+
minutes = (remaining.seconds % 3600) // 60
|
| 257 |
+
|
| 258 |
+
return f"{hours}時間{minutes}分後"
|
| 259 |
+
|
| 260 |
+
async def get_rate_limit_stats(self) -> Dict[str, Any]:
|
| 261 |
+
"""レート制限の統計情報を取得"""
|
| 262 |
+
try:
|
| 263 |
+
all_users = await self.storage.get_all_users()
|
| 264 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 265 |
+
|
| 266 |
+
total_requests_today = 0
|
| 267 |
+
total_api_calls_today = 0
|
| 268 |
+
active_users_today = 0
|
| 269 |
+
|
| 270 |
+
for user_id in all_users:
|
| 271 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 272 |
+
|
| 273 |
+
# 今日のリクエスト数
|
| 274 |
+
daily_requests = user_data["rate_limits"]["daily_requests"]
|
| 275 |
+
user_requests_today = daily_requests.get(today, 0)
|
| 276 |
+
total_requests_today += user_requests_today
|
| 277 |
+
|
| 278 |
+
# 今日のAPI呼び出し数
|
| 279 |
+
api_calls = user_data["rate_limits"]["api_calls"]
|
| 280 |
+
user_api_calls_today = api_calls.get(today, 0)
|
| 281 |
+
total_api_calls_today += user_api_calls_today
|
| 282 |
+
|
| 283 |
+
# アクティブユーザー数
|
| 284 |
+
if user_requests_today > 0 or user_api_calls_today > 0:
|
| 285 |
+
active_users_today += 1
|
| 286 |
+
|
| 287 |
+
return {
|
| 288 |
+
"total_users": len(all_users),
|
| 289 |
+
"active_users_today": active_users_today,
|
| 290 |
+
"total_requests_today": total_requests_today,
|
| 291 |
+
"total_api_calls_today": total_api_calls_today,
|
| 292 |
+
"max_daily_requests": self.max_daily_requests,
|
| 293 |
+
"max_api_calls_per_day": self.max_api_calls_per_day,
|
| 294 |
+
"debug_mode": self.debug_mode,
|
| 295 |
+
"date": today
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
except Exception as e:
|
| 299 |
+
logger.error(f"統計情報取得エラー: {e}")
|
| 300 |
+
return {"error": str(e)}
|
| 301 |
+
|
| 302 |
+
def is_debug_mode(self) -> bool:
|
| 303 |
+
"""デバッグモードかどうかを確認"""
|
| 304 |
+
return self.debug_mode
|
| 305 |
+
|
| 306 |
+
async def set_debug_mode(self, enabled: bool) -> None:
|
| 307 |
+
"""デバッグモードの設定(動的変更)"""
|
| 308 |
+
self.debug_mode = enabled
|
| 309 |
+
|
| 310 |
+
if enabled:
|
| 311 |
+
self.max_daily_requests = int(os.getenv("DEBUG_MAX_DAILY_REQUESTS", "10"))
|
| 312 |
+
self.max_api_calls_per_day = int(os.getenv("DEBUG_MAX_API_CALLS", "100"))
|
| 313 |
+
logger.info("デバッグモードを有効にしました")
|
| 314 |
+
else:
|
| 315 |
+
self.max_daily_requests = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
|
| 316 |
+
self.max_api_calls_per_day = int(os.getenv("MAX_API_CALLS_PER_DAY", "10"))
|
| 317 |
+
logger.info("デバッグモードを無効にしました")
|
| 318 |
+
|
| 319 |
+
async def force_reset_user_limits(self, user_id: str) -> None:
|
| 320 |
+
"""特定ユーザーの制限を強制リセット(デバッグ用)"""
|
| 321 |
+
if not self.debug_mode:
|
| 322 |
+
raise RateLimitError("デバッグモードでのみ利用可能です")
|
| 323 |
+
|
| 324 |
+
try:
|
| 325 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 326 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 327 |
+
|
| 328 |
+
# 今日の制限をリセット
|
| 329 |
+
user_data["rate_limits"]["daily_requests"][today] = 0
|
| 330 |
+
user_data["rate_limits"]["api_calls"][today] = 0
|
| 331 |
+
|
| 332 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 333 |
+
|
| 334 |
+
logger.info(f"ユーザー {user_id} の制限を強制リセットしました")
|
| 335 |
+
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"強制リセットエラー: {e}")
|
| 338 |
+
raise RateLimitError(f"制限のリセットに失敗しました: {e}")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# テスト用の関数
|
| 342 |
+
async def test_rate_limit_manager():
|
| 343 |
+
"""RateLimitManagerのテスト"""
|
| 344 |
+
import tempfile
|
| 345 |
+
import uuid
|
| 346 |
+
from async_storage_manager import AsyncStorageManager
|
| 347 |
+
|
| 348 |
+
# 一時ディレクトリでテスト
|
| 349 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 350 |
+
test_file = os.path.join(temp_dir, "test_letters.json")
|
| 351 |
+
storage = AsyncStorageManager(test_file)
|
| 352 |
+
rate_limiter = AsyncRateLimitManager(storage)
|
| 353 |
+
|
| 354 |
+
print("=== RateLimitManagerテスト開始 ===")
|
| 355 |
+
|
| 356 |
+
user_id = str(uuid.uuid4())
|
| 357 |
+
|
| 358 |
+
# 初回リクエストチェック
|
| 359 |
+
allowed, message = await rate_limiter.is_request_allowed(user_id)
|
| 360 |
+
print(f"✓ 初回リクエストチェック: {allowed} - {message}")
|
| 361 |
+
|
| 362 |
+
# リクエスト記録
|
| 363 |
+
await rate_limiter.record_request(user_id)
|
| 364 |
+
print("✓ リクエスト記録成功")
|
| 365 |
+
|
| 366 |
+
# API呼び出し記録
|
| 367 |
+
await rate_limiter.record_api_call(user_id, "groq")
|
| 368 |
+
print("✓ API呼び出し記録成功")
|
| 369 |
+
|
| 370 |
+
# 制限状況確認
|
| 371 |
+
status = await rate_limiter.get_user_limits_status(user_id)
|
| 372 |
+
print(f"✓ 制限状況確認: {status}")
|
| 373 |
+
|
| 374 |
+
# 統計情報取得
|
| 375 |
+
stats = await rate_limiter.get_rate_limit_stats()
|
| 376 |
+
print(f"✓ 統計情報取得: {stats}")
|
| 377 |
+
|
| 378 |
+
# デバッグモードテスト
|
| 379 |
+
await rate_limiter.set_debug_mode(True)
|
| 380 |
+
print("✓ デバッグモード有効化成功")
|
| 381 |
+
|
| 382 |
+
# 強制リセットテスト(デバッグモード時のみ)
|
| 383 |
+
await rate_limiter.force_reset_user_limits(user_id)
|
| 384 |
+
print("✓ 強制リセット成功")
|
| 385 |
+
|
| 386 |
+
print("=== 全てのテストが完了しました! ===")
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
if __name__ == "__main__":
|
| 390 |
+
asyncio.run(test_rate_limit_manager())
|
async_storage_manager.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
非同期ストレージ管理クラス
|
| 3 |
+
JSONファイルベースの永続ストレージを提供し、
|
| 4 |
+
非同期ファイル操作とロック機能を実装します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import shutil
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from typing import Dict, Any, Optional, List
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
+
# ログ設定
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class StorageError(Exception):
|
| 22 |
+
"""ストレージ関連のエラー"""
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class AsyncStorageManager:
|
| 27 |
+
"""非同期ストレージ管理クラス"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, file_path: str = "tmp/letters.json"):
|
| 30 |
+
self.file_path = Path(file_path)
|
| 31 |
+
self.backup_dir = self.file_path.parent / "backup"
|
| 32 |
+
self.lock = asyncio.Lock()
|
| 33 |
+
|
| 34 |
+
# ディレクトリの作成
|
| 35 |
+
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
self.backup_dir.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
|
| 38 |
+
# 初期データ構造
|
| 39 |
+
self.default_data = {
|
| 40 |
+
"users": {},
|
| 41 |
+
"system": {
|
| 42 |
+
"last_backup": None,
|
| 43 |
+
"batch_runs": {},
|
| 44 |
+
"created_at": datetime.now().isoformat()
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
async def load_data(self) -> Dict[str, Any]:
|
| 49 |
+
"""データファイルを読み込み"""
|
| 50 |
+
async with self.lock:
|
| 51 |
+
try:
|
| 52 |
+
if not self.file_path.exists():
|
| 53 |
+
logger.info("データファイルが存在しないため、初期データを作成します")
|
| 54 |
+
await self._save_data_unsafe(self.default_data)
|
| 55 |
+
return self.default_data.copy()
|
| 56 |
+
|
| 57 |
+
# ファイルサイズチェック
|
| 58 |
+
if self.file_path.stat().st_size == 0:
|
| 59 |
+
logger.warning("データファイルが空のため、初期データを作成します")
|
| 60 |
+
await self._save_data_unsafe(self.default_data)
|
| 61 |
+
return self.default_data.copy()
|
| 62 |
+
|
| 63 |
+
# JSONファイルの読み込み
|
| 64 |
+
with open(self.file_path, 'r', encoding='utf-8') as f:
|
| 65 |
+
data = json.load(f)
|
| 66 |
+
|
| 67 |
+
# データ構造の検証と修復
|
| 68 |
+
data = self._validate_and_repair_data(data)
|
| 69 |
+
|
| 70 |
+
logger.info(f"データファイルを正常に読み込みました: {self.file_path}")
|
| 71 |
+
return data
|
| 72 |
+
|
| 73 |
+
except json.JSONDecodeError as e:
|
| 74 |
+
logger.error(f"JSONファイルの形式が不正です: {e}")
|
| 75 |
+
# バックアップからの復旧を試行
|
| 76 |
+
return await self._restore_from_backup()
|
| 77 |
+
|
| 78 |
+
except Exception as e:
|
| 79 |
+
logger.error(f"データ読み込みエラー: {e}")
|
| 80 |
+
raise StorageError(f"データの読み込みに失敗しました: {e}")
|
| 81 |
+
|
| 82 |
+
async def save_data(self, data: Dict[str, Any]) -> None:
|
| 83 |
+
"""データファイルに保存"""
|
| 84 |
+
async with self.lock:
|
| 85 |
+
await self._save_data_unsafe(data)
|
| 86 |
+
|
| 87 |
+
async def _save_data_unsafe(self, data: Dict[str, Any]) -> None:
|
| 88 |
+
"""ロックなしでデータを保存(内部使用)"""
|
| 89 |
+
try:
|
| 90 |
+
# データの検証
|
| 91 |
+
validated_data = self._validate_and_repair_data(data)
|
| 92 |
+
|
| 93 |
+
# 一時ファイルに書き込み
|
| 94 |
+
temp_path = self.file_path.with_suffix('.tmp')
|
| 95 |
+
|
| 96 |
+
with open(temp_path, 'w', encoding='utf-8') as f:
|
| 97 |
+
json.dump(validated_data, f, ensure_ascii=False, indent=2)
|
| 98 |
+
|
| 99 |
+
# アトミックな移動
|
| 100 |
+
shutil.move(str(temp_path), str(self.file_path))
|
| 101 |
+
|
| 102 |
+
logger.info(f"データを正常に保存しました: {self.file_path}")
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"データ保存エラー: {e}")
|
| 106 |
+
# 一時ファイルのクリーンアップ
|
| 107 |
+
temp_path = self.file_path.with_suffix('.tmp')
|
| 108 |
+
if temp_path.exists():
|
| 109 |
+
temp_path.unlink()
|
| 110 |
+
raise StorageError(f"データの保存に失敗しました: {e}")
|
| 111 |
+
|
| 112 |
+
async def get_user_data(self, user_id: str) -> Dict[str, Any]:
|
| 113 |
+
"""特定ユーザーのデータを取得"""
|
| 114 |
+
data = await self.load_data()
|
| 115 |
+
|
| 116 |
+
if user_id not in data["users"]:
|
| 117 |
+
# 新規ユーザーの初期データを作成
|
| 118 |
+
user_data = {
|
| 119 |
+
"profile": {
|
| 120 |
+
"created_at": datetime.now().isoformat(),
|
| 121 |
+
"last_request": None,
|
| 122 |
+
"total_letters": 0
|
| 123 |
+
},
|
| 124 |
+
"letters": {},
|
| 125 |
+
"requests": {},
|
| 126 |
+
"rate_limits": {
|
| 127 |
+
"daily_requests": {},
|
| 128 |
+
"api_calls": {}
|
| 129 |
+
}
|
| 130 |
+
}
|
| 131 |
+
data["users"][user_id] = user_data
|
| 132 |
+
await self.save_data(data)
|
| 133 |
+
logger.info(f"新規ユーザーデータを作成しました: {user_id}")
|
| 134 |
+
|
| 135 |
+
return data["users"][user_id]
|
| 136 |
+
|
| 137 |
+
async def update_user_data(self, user_id: str, user_data: Dict[str, Any]) -> None:
|
| 138 |
+
"""特定ユーザーのデータを更新"""
|
| 139 |
+
data = await self.load_data()
|
| 140 |
+
data["users"][user_id] = user_data
|
| 141 |
+
await self.save_data(data)
|
| 142 |
+
logger.info(f"ユーザーデータを更新しました: {user_id}")
|
| 143 |
+
|
| 144 |
+
async def get_all_users(self) -> List[str]:
|
| 145 |
+
"""全ユーザーIDのリストを取得"""
|
| 146 |
+
data = await self.load_data()
|
| 147 |
+
return list(data["users"].keys())
|
| 148 |
+
|
| 149 |
+
async def backup_data(self) -> str:
|
| 150 |
+
"""データのバックアップを作成"""
|
| 151 |
+
try:
|
| 152 |
+
if not self.file_path.exists():
|
| 153 |
+
logger.warning("バックアップ対象のファイルが存在しません")
|
| 154 |
+
return ""
|
| 155 |
+
|
| 156 |
+
# バックアップファイル名(タイムスタンプ付き)
|
| 157 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 158 |
+
backup_filename = f"letters_backup_{timestamp}.json"
|
| 159 |
+
backup_path = self.backup_dir / backup_filename
|
| 160 |
+
|
| 161 |
+
# ファイルをコピー
|
| 162 |
+
shutil.copy2(str(self.file_path), str(backup_path))
|
| 163 |
+
|
| 164 |
+
# システム情報を更新
|
| 165 |
+
data = await self.load_data()
|
| 166 |
+
data["system"]["last_backup"] = datetime.now().isoformat()
|
| 167 |
+
await self.save_data(data)
|
| 168 |
+
|
| 169 |
+
logger.info(f"バックアップを作成しました: {backup_path}")
|
| 170 |
+
|
| 171 |
+
# 古いバックアップファイルを削除(7日以上前)
|
| 172 |
+
await self._cleanup_old_backups()
|
| 173 |
+
|
| 174 |
+
return str(backup_path)
|
| 175 |
+
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"バックアップ作成エラー: {e}")
|
| 178 |
+
raise StorageError(f"バックアップの作成に失敗しました: {e}")
|
| 179 |
+
|
| 180 |
+
async def _restore_from_backup(self) -> Dict[str, Any]:
|
| 181 |
+
"""最新のバックアップから復旧"""
|
| 182 |
+
try:
|
| 183 |
+
# バックアップファイルを検索
|
| 184 |
+
backup_files = list(self.backup_dir.glob("letters_backup_*.json"))
|
| 185 |
+
|
| 186 |
+
if not backup_files:
|
| 187 |
+
logger.warning("バックアップファイルが見つかりません。初期データを使用します")
|
| 188 |
+
await self._save_data_unsafe(self.default_data)
|
| 189 |
+
return self.default_data.copy()
|
| 190 |
+
|
| 191 |
+
# 最新のバックアップファイルを選択
|
| 192 |
+
latest_backup = max(backup_files, key=lambda p: p.stat().st_mtime)
|
| 193 |
+
|
| 194 |
+
logger.info(f"バックアップから復旧します: {latest_backup}")
|
| 195 |
+
|
| 196 |
+
with open(latest_backup, 'r', encoding='utf-8') as f:
|
| 197 |
+
data = json.load(f)
|
| 198 |
+
|
| 199 |
+
# 復旧したデータを保存
|
| 200 |
+
await self._save_data_unsafe(data)
|
| 201 |
+
|
| 202 |
+
return data
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"バックアップからの復旧に失敗: {e}")
|
| 206 |
+
logger.info("初期データを使用します")
|
| 207 |
+
await self._save_data_unsafe(self.default_data)
|
| 208 |
+
return self.default_data.copy()
|
| 209 |
+
|
| 210 |
+
async def _cleanup_old_backups(self, days: int = 7) -> None:
|
| 211 |
+
"""古いバックアップファイルを削除"""
|
| 212 |
+
try:
|
| 213 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 214 |
+
backup_files = list(self.backup_dir.glob("letters_backup_*.json"))
|
| 215 |
+
|
| 216 |
+
deleted_count = 0
|
| 217 |
+
for backup_file in backup_files:
|
| 218 |
+
file_time = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
| 219 |
+
if file_time < cutoff_date:
|
| 220 |
+
backup_file.unlink()
|
| 221 |
+
deleted_count += 1
|
| 222 |
+
|
| 223 |
+
if deleted_count > 0:
|
| 224 |
+
logger.info(f"{deleted_count}個の古いバックアップファイルを削除しました")
|
| 225 |
+
|
| 226 |
+
except Exception as e:
|
| 227 |
+
logger.error(f"バックアップファイルの削除エラー: {e}")
|
| 228 |
+
|
| 229 |
+
def _validate_and_repair_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
| 230 |
+
"""データ構造の検証と修復"""
|
| 231 |
+
if not isinstance(data, dict):
|
| 232 |
+
logger.warning("データが辞書形式ではありません。初期データを使用します")
|
| 233 |
+
return self.default_data.copy()
|
| 234 |
+
|
| 235 |
+
# 必要なキーの確認と修復
|
| 236 |
+
if "users" not in data:
|
| 237 |
+
data["users"] = {}
|
| 238 |
+
|
| 239 |
+
if "system" not in data:
|
| 240 |
+
data["system"] = self.default_data["system"].copy()
|
| 241 |
+
|
| 242 |
+
# システム情報の修復
|
| 243 |
+
system_defaults = {
|
| 244 |
+
"last_backup": None,
|
| 245 |
+
"batch_runs": {},
|
| 246 |
+
"created_at": datetime.now().isoformat()
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
for key, default_value in system_defaults.items():
|
| 250 |
+
if key not in data["system"]:
|
| 251 |
+
data["system"][key] = default_value
|
| 252 |
+
|
| 253 |
+
# ユーザーデータの修復
|
| 254 |
+
for user_id, user_data in data["users"].items():
|
| 255 |
+
if not isinstance(user_data, dict):
|
| 256 |
+
continue
|
| 257 |
+
|
| 258 |
+
# 必要なキーの確認
|
| 259 |
+
user_defaults = {
|
| 260 |
+
"profile": {
|
| 261 |
+
"created_at": datetime.now().isoformat(),
|
| 262 |
+
"last_request": None,
|
| 263 |
+
"total_letters": 0
|
| 264 |
+
},
|
| 265 |
+
"letters": {},
|
| 266 |
+
"requests": {},
|
| 267 |
+
"rate_limits": {
|
| 268 |
+
"daily_requests": {},
|
| 269 |
+
"api_calls": {}
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
for key, default_value in user_defaults.items():
|
| 274 |
+
if key not in user_data:
|
| 275 |
+
user_data[key] = default_value
|
| 276 |
+
|
| 277 |
+
return data
|
| 278 |
+
|
| 279 |
+
async def get_system_info(self) -> Dict[str, Any]:
|
| 280 |
+
"""システム情報を取得"""
|
| 281 |
+
data = await self.load_data()
|
| 282 |
+
return data["system"]
|
| 283 |
+
|
| 284 |
+
async def update_system_info(self, system_info: Dict[str, Any]) -> None:
|
| 285 |
+
"""システム情報を更新"""
|
| 286 |
+
data = await self.load_data()
|
| 287 |
+
data["system"].update(system_info)
|
| 288 |
+
await self.save_data(data)
|
| 289 |
+
|
| 290 |
+
async def cleanup_old_data(self, days: int = 90) -> int:
|
| 291 |
+
"""古いデータを削除"""
|
| 292 |
+
try:
|
| 293 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 294 |
+
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
|
| 295 |
+
|
| 296 |
+
data = await self.load_data()
|
| 297 |
+
deleted_count = 0
|
| 298 |
+
|
| 299 |
+
for user_id, user_data in data["users"].items():
|
| 300 |
+
# 古い手紙を削除
|
| 301 |
+
letters_to_delete = []
|
| 302 |
+
for date_str in user_data["letters"]:
|
| 303 |
+
if date_str < cutoff_str:
|
| 304 |
+
letters_to_delete.append(date_str)
|
| 305 |
+
|
| 306 |
+
for date_str in letters_to_delete:
|
| 307 |
+
del user_data["letters"][date_str]
|
| 308 |
+
deleted_count += 1
|
| 309 |
+
|
| 310 |
+
# 古いリクエストを削除
|
| 311 |
+
requests_to_delete = []
|
| 312 |
+
for date_str in user_data["requests"]:
|
| 313 |
+
if date_str < cutoff_str:
|
| 314 |
+
requests_to_delete.append(date_str)
|
| 315 |
+
|
| 316 |
+
for date_str in requests_to_delete:
|
| 317 |
+
del user_data["requests"][date_str]
|
| 318 |
+
|
| 319 |
+
# 古いレート制限データを削除
|
| 320 |
+
for limit_type in ["daily_requests", "api_calls"]:
|
| 321 |
+
if limit_type in user_data["rate_limits"]:
|
| 322 |
+
dates_to_delete = []
|
| 323 |
+
for date_str in user_data["rate_limits"][limit_type]:
|
| 324 |
+
if date_str < cutoff_str:
|
| 325 |
+
dates_to_delete.append(date_str)
|
| 326 |
+
|
| 327 |
+
for date_str in dates_to_delete:
|
| 328 |
+
del user_data["rate_limits"][limit_type][date_str]
|
| 329 |
+
|
| 330 |
+
if deleted_count > 0:
|
| 331 |
+
await self.save_data(data)
|
| 332 |
+
logger.info(f"{deleted_count}件の古いデータを削除しました")
|
| 333 |
+
|
| 334 |
+
return deleted_count
|
| 335 |
+
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"古いデータの削除エラー: {e}")
|
| 338 |
+
return 0
|
| 339 |
+
|
| 340 |
+
async def get_storage_stats(self) -> Dict[str, Any]:
|
| 341 |
+
"""ストレージの統計情報を取得"""
|
| 342 |
+
try:
|
| 343 |
+
data = await self.load_data()
|
| 344 |
+
|
| 345 |
+
total_users = len(data["users"])
|
| 346 |
+
total_letters = sum(len(user_data["letters"]) for user_data in data["users"].values())
|
| 347 |
+
total_requests = sum(len(user_data["requests"]) for user_data in data["users"].values())
|
| 348 |
+
|
| 349 |
+
file_size = self.file_path.stat().st_size if self.file_path.exists() else 0
|
| 350 |
+
backup_count = len(list(self.backup_dir.glob("letters_backup_*.json")))
|
| 351 |
+
|
| 352 |
+
return {
|
| 353 |
+
"total_users": total_users,
|
| 354 |
+
"total_letters": total_letters,
|
| 355 |
+
"total_requests": total_requests,
|
| 356 |
+
"file_size_bytes": file_size,
|
| 357 |
+
"backup_count": backup_count,
|
| 358 |
+
"last_backup": data["system"].get("last_backup"),
|
| 359 |
+
"created_at": data["system"].get("created_at")
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
except Exception as e:
|
| 363 |
+
logger.error(f"統計情報の取得エラー: {e}")
|
| 364 |
+
return {}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
# テスト用の関数
|
| 368 |
+
async def test_storage_manager():
|
| 369 |
+
"""StorageManagerのテスト"""
|
| 370 |
+
import tempfile
|
| 371 |
+
import uuid
|
| 372 |
+
|
| 373 |
+
# 一時ディレクトリでテスト
|
| 374 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 375 |
+
test_file = os.path.join(temp_dir, "test_letters.json")
|
| 376 |
+
storage = AsyncStorageManager(test_file)
|
| 377 |
+
|
| 378 |
+
print("=== StorageManagerテスト開始 ===")
|
| 379 |
+
|
| 380 |
+
# 初期データの読み込みテスト
|
| 381 |
+
data = await storage.load_data()
|
| 382 |
+
print("✓ 初期データの読み込み成功")
|
| 383 |
+
|
| 384 |
+
# ユーザーデータの作成テスト
|
| 385 |
+
user_id = str(uuid.uuid4())
|
| 386 |
+
user_data = await storage.get_user_data(user_id)
|
| 387 |
+
print("✓ ユーザーデータの作成成功")
|
| 388 |
+
|
| 389 |
+
# ユーザーデータの更新テスト
|
| 390 |
+
user_data["profile"]["total_letters"] = 1
|
| 391 |
+
user_data["letters"]["2024-01-20"] = {
|
| 392 |
+
"theme": "テストテーマ",
|
| 393 |
+
"content": "テスト手紙の内容",
|
| 394 |
+
"status": "completed",
|
| 395 |
+
"generated_at": datetime.now().isoformat()
|
| 396 |
+
}
|
| 397 |
+
await storage.update_user_data(user_id, user_data)
|
| 398 |
+
print("✓ ユーザーデータの更新成功")
|
| 399 |
+
|
| 400 |
+
# データの再読み込みテスト
|
| 401 |
+
updated_data = await storage.get_user_data(user_id)
|
| 402 |
+
assert updated_data["profile"]["total_letters"] == 1
|
| 403 |
+
print("✓ データの永続化確認成功")
|
| 404 |
+
|
| 405 |
+
# バックアップテスト
|
| 406 |
+
backup_path = await storage.backup_data()
|
| 407 |
+
print(f"✓ バックアップ作成成功: {backup_path}")
|
| 408 |
+
|
| 409 |
+
# 統計情報テスト
|
| 410 |
+
stats = await storage.get_storage_stats()
|
| 411 |
+
print(f"✓ 統計情報取得成功: {stats}")
|
| 412 |
+
|
| 413 |
+
print("=== 全てのテストが完了しました! ===")
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
if __name__ == "__main__":
|
| 417 |
+
asyncio.run(test_storage_manager())
|
background_processor.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
バックグラウンド処理統合クラス
|
| 3 |
+
Streamlitアプリと独立したバッチ処理の実行機能と
|
| 4 |
+
古いデータの自動削除機能を提供します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from typing import Dict, Any, Optional, Callable
|
| 13 |
+
import logging
|
| 14 |
+
import traceback
|
| 15 |
+
import signal
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
from batch_scheduler import BatchScheduler
|
| 19 |
+
from async_storage_manager import AsyncStorageManager
|
| 20 |
+
|
| 21 |
+
# ログ設定
|
| 22 |
+
logging.basicConfig(level=logging.INFO)
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class BackgroundProcessorError(Exception):
|
| 27 |
+
"""バックグラウンドプロセッサー関連のエラー"""
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class BackgroundProcessor:
|
| 32 |
+
"""Streamlitアプリと独立したバックグラウンド処理管理クラス"""
|
| 33 |
+
|
| 34 |
+
def __init__(self, storage_manager: Optional[AsyncStorageManager] = None):
|
| 35 |
+
"""
|
| 36 |
+
バックグラウンドプロセッサーを初期化
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
storage_manager: ストレージマネージャー(指定しない場合は新規作成)
|
| 40 |
+
"""
|
| 41 |
+
# ストレージマネージャーの初期化
|
| 42 |
+
if storage_manager is None:
|
| 43 |
+
storage_path = os.getenv("STORAGE_PATH", "/mnt/data/letters.json")
|
| 44 |
+
self.storage_manager = AsyncStorageManager(storage_path)
|
| 45 |
+
else:
|
| 46 |
+
self.storage_manager = storage_manager
|
| 47 |
+
|
| 48 |
+
# バッチスケジューラーの初期化
|
| 49 |
+
self.batch_scheduler = BatchScheduler(self.storage_manager)
|
| 50 |
+
|
| 51 |
+
# 設定値
|
| 52 |
+
self.target_hours = [2, 3, 4] # 2時、3時、4時
|
| 53 |
+
self.check_interval = int(os.getenv("BATCH_CHECK_INTERVAL", "60")) # 1分間隔
|
| 54 |
+
self.cleanup_hour = int(os.getenv("CLEANUP_HOUR", "1")) # 1時にクリーンアップ
|
| 55 |
+
self.cleanup_retention_days = int(os.getenv("CLEANUP_RETENTION_DAYS", "90"))
|
| 56 |
+
self.enable_background_processing = os.getenv("ENABLE_BACKGROUND_PROCESSING", "true").lower() == "true"
|
| 57 |
+
|
| 58 |
+
# 実行状態管理
|
| 59 |
+
self.is_running = False
|
| 60 |
+
self.background_thread = None
|
| 61 |
+
self.stop_event = threading.Event()
|
| 62 |
+
self.last_execution_times = {hour: None for hour in self.target_hours}
|
| 63 |
+
self.last_cleanup_date = None
|
| 64 |
+
|
| 65 |
+
# コールバック関数
|
| 66 |
+
self.on_batch_complete: Optional[Callable] = None
|
| 67 |
+
self.on_cleanup_complete: Optional[Callable] = None
|
| 68 |
+
self.on_error: Optional[Callable] = None
|
| 69 |
+
|
| 70 |
+
logger.info(f"BackgroundProcessor初期化完了 - 対象時刻: {self.target_hours}, チェック間隔: {self.check_interval}秒")
|
| 71 |
+
|
| 72 |
+
def start_background_processing(self) -> bool:
|
| 73 |
+
"""
|
| 74 |
+
バックグラウンド処理を開始
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
bool: 開始成功フラグ
|
| 78 |
+
"""
|
| 79 |
+
if not self.enable_background_processing:
|
| 80 |
+
logger.info("バックグラウンド処理は無効化されています")
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
if self.is_running:
|
| 84 |
+
logger.warning("バックグラウンド処理は既に実行中です")
|
| 85 |
+
return False
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
self.is_running = True
|
| 89 |
+
self.stop_event.clear()
|
| 90 |
+
|
| 91 |
+
# バックグラウンドスレッドを開始
|
| 92 |
+
self.background_thread = threading.Thread(
|
| 93 |
+
target=self._background_loop,
|
| 94 |
+
name="BackgroundProcessor",
|
| 95 |
+
daemon=True
|
| 96 |
+
)
|
| 97 |
+
self.background_thread.start()
|
| 98 |
+
|
| 99 |
+
# シグナルハンドラーを設定
|
| 100 |
+
self._setup_signal_handlers()
|
| 101 |
+
|
| 102 |
+
logger.info("バックグラウンド処理を開始しました")
|
| 103 |
+
return True
|
| 104 |
+
|
| 105 |
+
except Exception as e:
|
| 106 |
+
self.is_running = False
|
| 107 |
+
logger.error(f"バックグラウンド処理の開始に失敗: {str(e)}")
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
+
def stop_background_processing(self) -> bool:
|
| 111 |
+
"""
|
| 112 |
+
バックグラウンド処理を停止
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
bool: 停止成功フラグ
|
| 116 |
+
"""
|
| 117 |
+
if not self.is_running:
|
| 118 |
+
logger.info("バックグラウンド処理は実行されていません")
|
| 119 |
+
return True
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
logger.info("バックグラウンド処理の停止を開始します...")
|
| 123 |
+
|
| 124 |
+
# 停止フラグを設定
|
| 125 |
+
self.stop_event.set()
|
| 126 |
+
self.is_running = False
|
| 127 |
+
|
| 128 |
+
# スレッドの終了を待機
|
| 129 |
+
if self.background_thread and self.background_thread.is_alive():
|
| 130 |
+
self.background_thread.join(timeout=30) # 30秒でタイムアウト
|
| 131 |
+
|
| 132 |
+
if self.background_thread.is_alive():
|
| 133 |
+
logger.warning("バックグラウンドスレッドの停止がタイムアウトしました")
|
| 134 |
+
return False
|
| 135 |
+
|
| 136 |
+
logger.info("バックグラウンド処理を停止しました")
|
| 137 |
+
return True
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"バックグラウンド処理の停止に失敗: {str(e)}")
|
| 141 |
+
return False
|
| 142 |
+
|
| 143 |
+
def start_background_processing(self) -> bool:
|
| 144 |
+
if not self.enable_background_processing:
|
| 145 |
+
logger.info("バックグラウンド処理は無効化されています")
|
| 146 |
+
return False
|
| 147 |
+
if self.is_running:
|
| 148 |
+
logger.warning("バックグラウンド処理は既に実行中です")
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
try:
|
| 152 |
+
self.is_running = True
|
| 153 |
+
self.stop_event.clear()
|
| 154 |
+
|
| 155 |
+
# 変更点 1: スレッドのターゲットを新しいラッパー関数に変更
|
| 156 |
+
self.background_thread = threading.Thread(
|
| 157 |
+
target=self._thread_entry_point,
|
| 158 |
+
name="BackgroundProcessor",
|
| 159 |
+
daemon=True
|
| 160 |
+
)
|
| 161 |
+
self.background_thread.start()
|
| 162 |
+
|
| 163 |
+
self._setup_signal_handlers()
|
| 164 |
+
logger.info("バックグラウンド処理を開始しました")
|
| 165 |
+
return True
|
| 166 |
+
except Exception as e:
|
| 167 |
+
self.is_running = False
|
| 168 |
+
logger.error(f"バックグラウンド処理の開始に失敗: {e}")
|
| 169 |
+
return False
|
| 170 |
+
|
| 171 |
+
def stop_background_processing(self) -> bool:
|
| 172 |
+
if not self.is_running:
|
| 173 |
+
logger.info("バックグラウンド処理は実行されていません")
|
| 174 |
+
return True
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
logger.info("バックグラウンド処理の停止を開始します...")
|
| 178 |
+
self.stop_event.set()
|
| 179 |
+
if self.background_thread and self.background_thread.is_alive():
|
| 180 |
+
self.background_thread.join(timeout=10)
|
| 181 |
+
if self.background_thread.is_alive():
|
| 182 |
+
logger.warning("バックグラウンドスレッドの停止がタイムアウトしました")
|
| 183 |
+
return False
|
| 184 |
+
|
| 185 |
+
self.is_running = False
|
| 186 |
+
logger.info("バックグラウンド処理を停止しました")
|
| 187 |
+
return True
|
| 188 |
+
except Exception as e:
|
| 189 |
+
logger.error(f"バックグラウンド処理の停止に失敗: {e}")
|
| 190 |
+
return False
|
| 191 |
+
|
| 192 |
+
# 変更点 2: スレッドのエントリーポイントとなる同期ラッパー関数を追加
|
| 193 |
+
def _thread_entry_point(self) -> None:
|
| 194 |
+
"""バックグラウンドスレッド内でイベントループを実行するためのラッパー"""
|
| 195 |
+
loop = asyncio.new_event_loop()
|
| 196 |
+
asyncio.set_event_loop(loop)
|
| 197 |
+
try:
|
| 198 |
+
logger.info("バックグラウンドスレッドのイベントループを開始します。")
|
| 199 |
+
loop.run_until_complete(self._background_loop())
|
| 200 |
+
except Exception as e:
|
| 201 |
+
logger.error(f"バックグラウンドイベントループで致命的なエラー: {e}\n{traceback.format_exc()}")
|
| 202 |
+
finally:
|
| 203 |
+
logger.info("バックグラウンドスレッドのイベントループを終了します。")
|
| 204 |
+
loop.close()
|
| 205 |
+
|
| 206 |
+
# 変更点 3: メインループを async def に変更
|
| 207 |
+
async def _background_loop(self) -> None:
|
| 208 |
+
"""バックグラウンド処理の非同期メインループ"""
|
| 209 |
+
logger.info("非同期バックグラウンド処理ループを開始します")
|
| 210 |
+
while not self.stop_event.is_set():
|
| 211 |
+
try:
|
| 212 |
+
current_time = datetime.now()
|
| 213 |
+
current_hour = current_time.hour
|
| 214 |
+
current_date = current_time.strftime("%Y-%m-%d")
|
| 215 |
+
|
| 216 |
+
if current_hour in self.target_hours:
|
| 217 |
+
await self._check_and_run_batch(current_hour, current_date)
|
| 218 |
+
|
| 219 |
+
if current_hour == self.cleanup_hour:
|
| 220 |
+
await self._check_and_run_cleanup(current_date)
|
| 221 |
+
|
| 222 |
+
# 変更点 4: 待機処理を asyncio.sleep に変更
|
| 223 |
+
# stop_eventをチェックしながら1秒ずつ待機することで、素早い停止を可能にする
|
| 224 |
+
for _ in range(self.check_interval):
|
| 225 |
+
if self.stop_event.is_set():
|
| 226 |
+
break
|
| 227 |
+
await asyncio.sleep(1)
|
| 228 |
+
|
| 229 |
+
except Exception as e:
|
| 230 |
+
error_msg = f"バックグラウンド処理ループでエラーが発生: {e}"
|
| 231 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 232 |
+
if self.on_error:
|
| 233 |
+
try:
|
| 234 |
+
self.on_error(error_msg)
|
| 235 |
+
except Exception as callback_error:
|
| 236 |
+
logger.error(f"エラーコールバック実行エラー: {callback_error}")
|
| 237 |
+
await asyncio.sleep(min(self.check_interval * 2, 300))
|
| 238 |
+
|
| 239 |
+
logger.info("非同期バックグラウンド処理ループを終了しました")
|
| 240 |
+
|
| 241 |
+
async def _check_and_run_batch(self, hour: int, date: str) -> None:
|
| 242 |
+
"""
|
| 243 |
+
バッチ処理の実行チェックと実行
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
hour: 現在の時刻
|
| 247 |
+
date: 現在の日付
|
| 248 |
+
"""
|
| 249 |
+
try:
|
| 250 |
+
# 既に今日実行済みかチェック
|
| 251 |
+
last_execution = self.last_execution_times.get(hour)
|
| 252 |
+
if last_execution and last_execution == date:
|
| 253 |
+
return # 既に実行済み
|
| 254 |
+
|
| 255 |
+
logger.info(f"{hour}時のバッチ処理を実行します")
|
| 256 |
+
|
| 257 |
+
# バッチ処理を実行
|
| 258 |
+
result = await self.batch_scheduler.run_hourly_batch(hour)
|
| 259 |
+
|
| 260 |
+
# 実行時刻を記録
|
| 261 |
+
self.last_execution_times[hour] = date
|
| 262 |
+
|
| 263 |
+
# 完了コールバックを呼び出し
|
| 264 |
+
if self.on_batch_complete:
|
| 265 |
+
try:
|
| 266 |
+
self.on_batch_complete(hour, result)
|
| 267 |
+
except Exception as callback_error:
|
| 268 |
+
logger.error(f"バッチ完了コールバック実行エラー: {str(callback_error)}")
|
| 269 |
+
|
| 270 |
+
if result.get("success", False):
|
| 271 |
+
logger.info(f"{hour}時のバッチ処理が完了しました - 処理数: {result.get('processed_count', 0)}")
|
| 272 |
+
else:
|
| 273 |
+
logger.error(f"{hour}時のバッチ処理が失敗しました: {result.get('error', '不明なエラー')}")
|
| 274 |
+
|
| 275 |
+
except Exception as e:
|
| 276 |
+
error_msg = f"{hour}時のバッチ処理チェック中にエラーが発生: {str(e)}"
|
| 277 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 278 |
+
|
| 279 |
+
if self.on_error:
|
| 280 |
+
try:
|
| 281 |
+
self.on_error(error_msg)
|
| 282 |
+
except Exception as callback_error:
|
| 283 |
+
logger.error(f"エラーコールバック実行エラー: {str(callback_error)}")
|
| 284 |
+
|
| 285 |
+
async def _check_and_run_cleanup(self, date: str) -> None:
|
| 286 |
+
"""
|
| 287 |
+
クリーンアップ処理の実行チェックと実行
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
date: 現在の日付
|
| 291 |
+
"""
|
| 292 |
+
try:
|
| 293 |
+
# 既に今日実行済みかチェック
|
| 294 |
+
if self.last_cleanup_date == date:
|
| 295 |
+
return # 既に実行済み
|
| 296 |
+
|
| 297 |
+
logger.info("古いデータのクリーンアップを実行します")
|
| 298 |
+
|
| 299 |
+
# クリーンアップ処理を実行
|
| 300 |
+
result = await self.batch_scheduler.cleanup_old_data(self.cleanup_retention_days)
|
| 301 |
+
|
| 302 |
+
# 実行日を記録
|
| 303 |
+
self.last_cleanup_date = date
|
| 304 |
+
|
| 305 |
+
# 完了コールバックを呼び出し
|
| 306 |
+
if self.on_cleanup_complete:
|
| 307 |
+
try:
|
| 308 |
+
self.on_cleanup_complete(result)
|
| 309 |
+
except Exception as callback_error:
|
| 310 |
+
logger.error(f"クリーンアップ完了コールバック実行エラー: {str(callback_error)}")
|
| 311 |
+
|
| 312 |
+
if result.get("success", False):
|
| 313 |
+
logger.info(f"クリーンアップが完了しました - 削除数: {result.get('deleted_letters', 0)}")
|
| 314 |
+
else:
|
| 315 |
+
logger.error(f"クリーンアップが失敗しました: {result.get('error', '不明なエラー')}")
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
error_msg = f"クリーンアップ処理チェック中にエラーが発生: {str(e)}"
|
| 319 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 320 |
+
|
| 321 |
+
if self.on_error:
|
| 322 |
+
try:
|
| 323 |
+
self.on_error(error_msg)
|
| 324 |
+
except Exception as callback_error:
|
| 325 |
+
logger.error(f"エラーコールバック実行エラー: {str(callback_error)}")
|
| 326 |
+
|
| 327 |
+
def _setup_signal_handlers(self) -> None:
|
| 328 |
+
"""シグナルハンドラーを設定"""
|
| 329 |
+
def signal_handler(signum, frame):
|
| 330 |
+
logger.info(f"シグナル {signum} を受信しました。バックグラウンド処理を停止します...")
|
| 331 |
+
self.stop_background_processing()
|
| 332 |
+
sys.exit(0)
|
| 333 |
+
|
| 334 |
+
# SIGTERM と SIGINT のハンドラーを設定
|
| 335 |
+
signal.signal(signal.SIGTERM, signal_handler)
|
| 336 |
+
signal.signal(signal.SIGINT, signal_handler)
|
| 337 |
+
|
| 338 |
+
def get_status(self) -> Dict[str, Any]:
|
| 339 |
+
"""
|
| 340 |
+
バックグラウンド処理の状態を取得
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
Dict: 状態情報
|
| 344 |
+
"""
|
| 345 |
+
return {
|
| 346 |
+
"is_running": self.is_running,
|
| 347 |
+
"enable_background_processing": self.enable_background_processing,
|
| 348 |
+
"target_hours": self.target_hours,
|
| 349 |
+
"check_interval": self.check_interval,
|
| 350 |
+
"cleanup_hour": self.cleanup_hour,
|
| 351 |
+
"cleanup_retention_days": self.cleanup_retention_days,
|
| 352 |
+
"last_execution_times": self.last_execution_times.copy(),
|
| 353 |
+
"last_cleanup_date": self.last_cleanup_date,
|
| 354 |
+
"thread_alive": self.background_thread.is_alive() if self.background_thread else False,
|
| 355 |
+
"current_time": datetime.now().isoformat()
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
async def force_run_batch(self, hour: int) -> Dict[str, Any]:
|
| 359 |
+
"""
|
| 360 |
+
指定時刻のバッチ処理を強制実行
|
| 361 |
+
|
| 362 |
+
Args:
|
| 363 |
+
hour: 実行対象の時刻
|
| 364 |
+
|
| 365 |
+
Returns:
|
| 366 |
+
Dict: 実行結果
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
if hour not in self.target_hours:
|
| 370 |
+
return {
|
| 371 |
+
"success": False,
|
| 372 |
+
"error": f"無効な時刻が指定されました: {hour} (有効: {self.target_hours})"
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
logger.info(f"{hour}時のバッチ処理を強制実行します")
|
| 376 |
+
|
| 377 |
+
result = await self.batch_scheduler.run_hourly_batch(hour)
|
| 378 |
+
|
| 379 |
+
# 実行時刻を記録
|
| 380 |
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
| 381 |
+
self.last_execution_times[hour] = current_date
|
| 382 |
+
|
| 383 |
+
return result
|
| 384 |
+
|
| 385 |
+
except Exception as e:
|
| 386 |
+
error_msg = f"バッチ処理の強制実行中にエラーが発生: {str(e)}"
|
| 387 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 388 |
+
return {
|
| 389 |
+
"success": False,
|
| 390 |
+
"error": error_msg
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
async def force_run_cleanup(self) -> Dict[str, Any]:
|
| 394 |
+
"""
|
| 395 |
+
クリーンアップ処理を強制実行
|
| 396 |
+
|
| 397 |
+
Returns:
|
| 398 |
+
Dict: 実行結果
|
| 399 |
+
"""
|
| 400 |
+
try:
|
| 401 |
+
logger.info("クリーンアップ処理を強制実行します")
|
| 402 |
+
|
| 403 |
+
result = await self.batch_scheduler.cleanup_old_data(self.cleanup_retention_days)
|
| 404 |
+
|
| 405 |
+
# 実行日を記録
|
| 406 |
+
current_date = datetime.now().strftime("%Y-%m-%d")
|
| 407 |
+
self.last_cleanup_date = current_date
|
| 408 |
+
|
| 409 |
+
return result
|
| 410 |
+
|
| 411 |
+
except Exception as e:
|
| 412 |
+
error_msg = f"クリーンアップ処理の強制実行中にエラーが発生: {str(e)}"
|
| 413 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 414 |
+
return {
|
| 415 |
+
"success": False,
|
| 416 |
+
"error": error_msg
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
def set_callbacks(self,
|
| 420 |
+
on_batch_complete: Optional[Callable] = None,
|
| 421 |
+
on_cleanup_complete: Optional[Callable] = None,
|
| 422 |
+
on_error: Optional[Callable] = None) -> None:
|
| 423 |
+
"""
|
| 424 |
+
コールバック関数を設定
|
| 425 |
+
|
| 426 |
+
Args:
|
| 427 |
+
on_batch_complete: バッチ処理完了時のコールバック
|
| 428 |
+
on_cleanup_complete: クリーンアップ完了時のコールバック
|
| 429 |
+
on_error: エラー発生時のコールバック
|
| 430 |
+
"""
|
| 431 |
+
self.on_batch_complete = on_batch_complete
|
| 432 |
+
self.on_cleanup_complete = on_cleanup_complete
|
| 433 |
+
self.on_error = on_error
|
| 434 |
+
|
| 435 |
+
logger.info("コールバック関数を設定しました")
|
| 436 |
+
|
| 437 |
+
async def get_processing_statistics(self, days: int = 7) -> Dict[str, Any]:
|
| 438 |
+
"""
|
| 439 |
+
処理統計情報を取得
|
| 440 |
+
|
| 441 |
+
Args:
|
| 442 |
+
days: 統計対象日数
|
| 443 |
+
|
| 444 |
+
Returns:
|
| 445 |
+
Dict: 統計情報
|
| 446 |
+
"""
|
| 447 |
+
try:
|
| 448 |
+
# バッチスケジューラーから統計を取得
|
| 449 |
+
batch_stats = await self.batch_scheduler.get_batch_statistics(days)
|
| 450 |
+
|
| 451 |
+
# ストレージ統計を取得
|
| 452 |
+
storage_stats = await self.storage_manager.get_storage_stats()
|
| 453 |
+
|
| 454 |
+
# バックグラウンド処理の状態を追加
|
| 455 |
+
status = self.get_status()
|
| 456 |
+
|
| 457 |
+
return {
|
| 458 |
+
"background_processor": status,
|
| 459 |
+
"batch_statistics": batch_stats,
|
| 460 |
+
"storage_statistics": storage_stats,
|
| 461 |
+
"generated_at": datetime.now().isoformat()
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
except Exception as e:
|
| 465 |
+
logger.error(f"統計情報取得エラー: {str(e)}")
|
| 466 |
+
return {"error": str(e)}
|
| 467 |
+
|
| 468 |
+
def __enter__(self):
|
| 469 |
+
"""コンテキストマネージャーのエントリー"""
|
| 470 |
+
self.start_background_processing()
|
| 471 |
+
return self
|
| 472 |
+
|
| 473 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 474 |
+
"""コンテキストマネージャーの終了"""
|
| 475 |
+
self.stop_background_processing()
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
class StreamlitBackgroundIntegration:
|
| 479 |
+
"""Streamlitアプリとバックグラウンド処理の統合クラス"""
|
| 480 |
+
|
| 481 |
+
def __init__(self):
|
| 482 |
+
self.background_processor = None
|
| 483 |
+
self.is_initialized = False
|
| 484 |
+
|
| 485 |
+
def initialize(self, storage_manager: Optional[AsyncStorageManager] = None) -> bool:
|
| 486 |
+
"""
|
| 487 |
+
バックグラウンド処理を初期化
|
| 488 |
+
|
| 489 |
+
Args:
|
| 490 |
+
storage_manager: ストレージマネージャー
|
| 491 |
+
|
| 492 |
+
Returns:
|
| 493 |
+
bool: 初期化成功フラグ
|
| 494 |
+
"""
|
| 495 |
+
try:
|
| 496 |
+
if self.is_initialized:
|
| 497 |
+
return True
|
| 498 |
+
|
| 499 |
+
self.background_processor = BackgroundProcessor(storage_manager)
|
| 500 |
+
|
| 501 |
+
# コールバック関数を設定
|
| 502 |
+
self.background_processor.set_callbacks(
|
| 503 |
+
on_batch_complete=self._on_batch_complete,
|
| 504 |
+
on_cleanup_complete=self._on_cleanup_complete,
|
| 505 |
+
on_error=self._on_error
|
| 506 |
+
)
|
| 507 |
+
|
| 508 |
+
# バックグラウンド処理を開始
|
| 509 |
+
success = self.background_processor.start_background_processing()
|
| 510 |
+
|
| 511 |
+
if success:
|
| 512 |
+
self.is_initialized = True
|
| 513 |
+
logger.info("Streamlitバックグラウンド統合を初期化しました")
|
| 514 |
+
|
| 515 |
+
return success
|
| 516 |
+
|
| 517 |
+
except Exception as e:
|
| 518 |
+
logger.error(f"バックグラウンド統合の初期化に失敗: {str(e)}")
|
| 519 |
+
return False
|
| 520 |
+
|
| 521 |
+
def shutdown(self) -> bool:
|
| 522 |
+
"""
|
| 523 |
+
バックグラウンド処理を終了
|
| 524 |
+
|
| 525 |
+
Returns:
|
| 526 |
+
bool: 終了成功フラグ
|
| 527 |
+
"""
|
| 528 |
+
try:
|
| 529 |
+
if not self.is_initialized or not self.background_processor:
|
| 530 |
+
return True
|
| 531 |
+
|
| 532 |
+
success = self.background_processor.stop_background_processing()
|
| 533 |
+
|
| 534 |
+
if success:
|
| 535 |
+
self.is_initialized = False
|
| 536 |
+
logger.info("Streamlitバックグラウンド統合を終了しました")
|
| 537 |
+
|
| 538 |
+
return success
|
| 539 |
+
|
| 540 |
+
except Exception as e:
|
| 541 |
+
logger.error(f"バックグラウンド統合の終了に失敗: {str(e)}")
|
| 542 |
+
return False
|
| 543 |
+
|
| 544 |
+
def get_status(self) -> Dict[str, Any]:
|
| 545 |
+
"""統合状態を取得"""
|
| 546 |
+
if not self.is_initialized or not self.background_processor:
|
| 547 |
+
return {"initialized": False, "running": False}
|
| 548 |
+
|
| 549 |
+
status = self.background_processor.get_status()
|
| 550 |
+
status["initialized"] = self.is_initialized
|
| 551 |
+
|
| 552 |
+
return status
|
| 553 |
+
|
| 554 |
+
async def get_statistics(self, days: int = 7) -> Dict[str, Any]:
|
| 555 |
+
"""統計情報を取得"""
|
| 556 |
+
if not self.is_initialized or not self.background_processor:
|
| 557 |
+
return {"error": "バックグラウンド処理が初期化されていません"}
|
| 558 |
+
|
| 559 |
+
return await self.background_processor.get_processing_statistics(days)
|
| 560 |
+
|
| 561 |
+
def _on_batch_complete(self, hour: int, result: Dict[str, Any]) -> None:
|
| 562 |
+
"""バッチ処理完了時のコールバック"""
|
| 563 |
+
logger.info(f"バッチ処理完了通知 - {hour}時: {result.get('success', False)}")
|
| 564 |
+
# Streamlitの状態更新やキャッシュクリアなどを実装可能
|
| 565 |
+
|
| 566 |
+
def _on_cleanup_complete(self, result: Dict[str, Any]) -> None:
|
| 567 |
+
"""クリーンアップ完了時のコールバック"""
|
| 568 |
+
logger.info(f"クリーンアップ完了通知: {result.get('success', False)}")
|
| 569 |
+
# Streamlitの状態更新やキャッシュクリアなどを実装可能
|
| 570 |
+
|
| 571 |
+
def _on_error(self, error_message: str) -> None:
|
| 572 |
+
"""エラー発生時のコールバック"""
|
| 573 |
+
logger.error(f"バックグラウンド処理エラー通知: {error_message}")
|
| 574 |
+
# Streamlitのエラー表示やアラート機能を実装可能
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
# グローバルインスタンス(Streamlitアプリで使用)
|
| 578 |
+
streamlit_background = StreamlitBackgroundIntegration()
|
| 579 |
+
|
| 580 |
+
|
| 581 |
+
# テスト用の関数
|
| 582 |
+
async def test_background_processor():
|
| 583 |
+
"""BackgroundProcessorのテスト"""
|
| 584 |
+
import tempfile
|
| 585 |
+
|
| 586 |
+
# 一時ディレクトリでテスト
|
| 587 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 588 |
+
test_file = os.path.join(temp_dir, "test_letters.json")
|
| 589 |
+
storage = AsyncStorageManager(test_file)
|
| 590 |
+
|
| 591 |
+
print("=== BackgroundProcessorテスト開始 ===")
|
| 592 |
+
|
| 593 |
+
# バックグラウンドプロセッサーのテスト
|
| 594 |
+
processor = BackgroundProcessor(storage)
|
| 595 |
+
|
| 596 |
+
# 状態確認テスト
|
| 597 |
+
status = processor.get_status()
|
| 598 |
+
print(f"✓ 状態確認テスト: {status['is_running']}")
|
| 599 |
+
|
| 600 |
+
# 強制バッチ実行テスト
|
| 601 |
+
batch_result = await processor.force_run_batch(2)
|
| 602 |
+
print(f"✓ 強制バッチ実行テスト: {batch_result['success']}")
|
| 603 |
+
|
| 604 |
+
# 強制クリーンアップテスト
|
| 605 |
+
cleanup_result = await processor.force_run_cleanup()
|
| 606 |
+
print(f"✓ 強制クリーンアップテスト: {cleanup_result['success']}")
|
| 607 |
+
|
| 608 |
+
# 統計情報テスト
|
| 609 |
+
stats = await processor.get_processing_statistics()
|
| 610 |
+
print(f"✓ 統計情報取得テスト: {'error' not in stats}")
|
| 611 |
+
|
| 612 |
+
print("=== 全てのテストが完了しました! ===")
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
if __name__ == "__main__":
|
| 616 |
+
asyncio.run(test_background_processor())
|
batch_scheduler.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
バッチスケジューラークラス
|
| 3 |
+
2時、3時、4時の時刻別バッチ処理機能を実装し、
|
| 4 |
+
指定時刻の未処理リクエストを処理する機能を提供します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Dict, Any, List, Optional
|
| 11 |
+
import logging
|
| 12 |
+
import traceback
|
| 13 |
+
|
| 14 |
+
from letter_request_manager import RequestManager
|
| 15 |
+
from letter_generator import LetterGenerator
|
| 16 |
+
from async_storage_manager import AsyncStorageManager
|
| 17 |
+
from async_rate_limiter import AsyncRateLimitManager
|
| 18 |
+
from letter_user_manager import UserManager
|
| 19 |
+
|
| 20 |
+
# ログ設定
|
| 21 |
+
logging.basicConfig(level=logging.INFO)
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class BatchSchedulerError(Exception):
|
| 26 |
+
"""バッチスケジューラー関連のエラー"""
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class BatchScheduler:
|
| 31 |
+
"""非同期バッチ処理スケジューラー"""
|
| 32 |
+
|
| 33 |
+
def __init__(self, storage_manager: Optional[AsyncStorageManager] = None):
|
| 34 |
+
"""
|
| 35 |
+
バッチスケジューラーを初期化
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
storage_manager: ストレージマネージャー(指定しない場合は新規作成)
|
| 39 |
+
"""
|
| 40 |
+
# ストレージマネージャーの初期化
|
| 41 |
+
if storage_manager is None:
|
| 42 |
+
storage_path = os.getenv("STORAGE_PATH", "/mnt/data/letters.json")
|
| 43 |
+
self.storage_manager = AsyncStorageManager(storage_path)
|
| 44 |
+
else:
|
| 45 |
+
self.storage_manager = storage_manager
|
| 46 |
+
|
| 47 |
+
# 依存コンポーネントの初期化
|
| 48 |
+
self.rate_limiter = AsyncRateLimitManager(self.storage_manager)
|
| 49 |
+
self.request_manager = RequestManager(self.storage_manager, self.rate_limiter)
|
| 50 |
+
self.letter_generator = LetterGenerator()
|
| 51 |
+
self.user_manager = UserManager(self.storage_manager)
|
| 52 |
+
|
| 53 |
+
# 設定値
|
| 54 |
+
self.available_hours = [2, 3, 4] # 2時、3時、4時
|
| 55 |
+
self.max_concurrent_generations = int(os.getenv("MAX_CONCURRENT_GENERATIONS", "3"))
|
| 56 |
+
self.generation_timeout = int(os.getenv("GENERATION_TIMEOUT", "300")) # 5分
|
| 57 |
+
self.retry_failed_requests = os.getenv("RETRY_FAILED_REQUESTS", "true").lower() == "true"
|
| 58 |
+
|
| 59 |
+
logger.info(f"BatchScheduler初期化完了 - 対象時刻: {self.available_hours}")
|
| 60 |
+
|
| 61 |
+
async def run_hourly_batch(self, hour: int) -> Dict[str, Any]:
|
| 62 |
+
"""
|
| 63 |
+
指定時刻のバッチ処理を実行
|
| 64 |
+
|
| 65 |
+
Args:
|
| 66 |
+
hour: 実行時刻(2, 3, 4のいずれか)
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
Dict: バッチ処理の結果
|
| 70 |
+
"""
|
| 71 |
+
start_time = datetime.now()
|
| 72 |
+
batch_id = f"{start_time.strftime('%Y%m%d_%H%M%S')}_{hour}"
|
| 73 |
+
|
| 74 |
+
logger.info(f"=== {hour}時のバッチ処理開始 (ID: {batch_id}) ===")
|
| 75 |
+
|
| 76 |
+
# 時刻の検証
|
| 77 |
+
if hour not in self.available_hours:
|
| 78 |
+
error_msg = f"無効な時刻が指定されました: {hour}時 (有効: {self.available_hours})"
|
| 79 |
+
logger.error(error_msg)
|
| 80 |
+
return {
|
| 81 |
+
"success": False,
|
| 82 |
+
"error": error_msg,
|
| 83 |
+
"batch_id": batch_id,
|
| 84 |
+
"hour": hour,
|
| 85 |
+
"start_time": start_time.isoformat(),
|
| 86 |
+
"end_time": datetime.now().isoformat()
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
# バッチ実行の記録開始
|
| 91 |
+
await self._record_batch_start(batch_id, hour, start_time)
|
| 92 |
+
|
| 93 |
+
# 指定時刻の未処理リクエストを処理
|
| 94 |
+
result = await self.process_pending_requests_for_hour(hour)
|
| 95 |
+
|
| 96 |
+
# バッチ実行の記録完了
|
| 97 |
+
end_time = datetime.now()
|
| 98 |
+
await self._record_batch_completion(batch_id, hour, start_time, end_time, result)
|
| 99 |
+
|
| 100 |
+
execution_time = (end_time - start_time).total_seconds()
|
| 101 |
+
|
| 102 |
+
logger.info(f"=== {hour}時のバッチ処理完了 (所要時間: {execution_time:.2f}秒) ===")
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"success": True,
|
| 106 |
+
"batch_id": batch_id,
|
| 107 |
+
"hour": hour,
|
| 108 |
+
"start_time": start_time.isoformat(),
|
| 109 |
+
"end_time": end_time.isoformat(),
|
| 110 |
+
"execution_time": execution_time,
|
| 111 |
+
"processed_count": result.get("processed_count", 0),
|
| 112 |
+
"success_count": result.get("success_count", 0),
|
| 113 |
+
"failed_count": result.get("failed_count", 0),
|
| 114 |
+
"errors": result.get("errors", [])
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
except Exception as e:
|
| 118 |
+
end_time = datetime.now()
|
| 119 |
+
execution_time = (end_time - start_time).total_seconds()
|
| 120 |
+
error_msg = f"バッチ処理中にエラーが発生しました: {str(e)}"
|
| 121 |
+
|
| 122 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 123 |
+
|
| 124 |
+
# エラーの記録
|
| 125 |
+
await self._record_batch_error(batch_id, hour, start_time, end_time, error_msg)
|
| 126 |
+
|
| 127 |
+
return {
|
| 128 |
+
"success": False,
|
| 129 |
+
"error": error_msg,
|
| 130 |
+
"batch_id": batch_id,
|
| 131 |
+
"hour": hour,
|
| 132 |
+
"start_time": start_time.isoformat(),
|
| 133 |
+
"end_time": end_time.isoformat(),
|
| 134 |
+
"execution_time": execution_time
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
async def process_pending_requests_for_hour(self, hour: int) -> Dict[str, Any]:
|
| 138 |
+
"""
|
| 139 |
+
指定時刻の未処理リクエストを処理
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
hour: 処理対象の時刻
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
Dict: 処理結果の詳細
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
# 未処理リクエストを取得
|
| 149 |
+
pending_requests = await self.request_manager.get_pending_requests_by_hour(hour)
|
| 150 |
+
|
| 151 |
+
if not pending_requests:
|
| 152 |
+
logger.info(f"{hour}時の未処理リクエストはありません")
|
| 153 |
+
return {
|
| 154 |
+
"processed_count": 0,
|
| 155 |
+
"success_count": 0,
|
| 156 |
+
"failed_count": 0,
|
| 157 |
+
"errors": []
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
logger.info(f"{hour}時の未処理リクエスト数: {len(pending_requests)}")
|
| 161 |
+
|
| 162 |
+
# 並行処理用のセマフォ
|
| 163 |
+
semaphore = asyncio.Semaphore(self.max_concurrent_generations)
|
| 164 |
+
|
| 165 |
+
# 各リクエストを並行処理
|
| 166 |
+
tasks = []
|
| 167 |
+
for request in pending_requests:
|
| 168 |
+
task = self._process_single_request(semaphore, request)
|
| 169 |
+
tasks.append(task)
|
| 170 |
+
|
| 171 |
+
# 全てのタスクを実行
|
| 172 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 173 |
+
|
| 174 |
+
# 結果を集計
|
| 175 |
+
success_count = 0
|
| 176 |
+
failed_count = 0
|
| 177 |
+
errors = []
|
| 178 |
+
|
| 179 |
+
for i, result in enumerate(results):
|
| 180 |
+
if isinstance(result, Exception):
|
| 181 |
+
failed_count += 1
|
| 182 |
+
error_msg = f"リクエスト処理例外: {str(result)}"
|
| 183 |
+
errors.append({
|
| 184 |
+
"request_index": i,
|
| 185 |
+
"user_id": pending_requests[i].get("user_id", "unknown"),
|
| 186 |
+
"error": error_msg
|
| 187 |
+
})
|
| 188 |
+
logger.error(error_msg)
|
| 189 |
+
elif result.get("success", False):
|
| 190 |
+
success_count += 1
|
| 191 |
+
else:
|
| 192 |
+
failed_count += 1
|
| 193 |
+
errors.append({
|
| 194 |
+
"request_index": i,
|
| 195 |
+
"user_id": pending_requests[i].get("user_id", "unknown"),
|
| 196 |
+
"error": result.get("error", "不明なエラー")
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
logger.info(f"処理完了 - 成功: {success_count}, 失敗: {failed_count}")
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
"processed_count": len(pending_requests),
|
| 203 |
+
"success_count": success_count,
|
| 204 |
+
"failed_count": failed_count,
|
| 205 |
+
"errors": errors
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
except Exception as e:
|
| 209 |
+
error_msg = f"未処理リクエスト処理中にエラーが発生: {str(e)}"
|
| 210 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 211 |
+
return {
|
| 212 |
+
"processed_count": 0,
|
| 213 |
+
"success_count": 0,
|
| 214 |
+
"failed_count": 0,
|
| 215 |
+
"errors": [{"error": error_msg}]
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
async def _process_single_request(self, semaphore: asyncio.Semaphore, request: Dict[str, Any]) -> Dict[str, Any]:
|
| 219 |
+
"""
|
| 220 |
+
単一のリクエストを処理
|
| 221 |
+
|
| 222 |
+
Args:
|
| 223 |
+
semaphore: 並行処理制御用のセマフォ
|
| 224 |
+
request: 処理対象のリクエスト
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
Dict: 処理結果
|
| 228 |
+
"""
|
| 229 |
+
async with semaphore:
|
| 230 |
+
user_id = request["user_id"]
|
| 231 |
+
theme = request["theme"]
|
| 232 |
+
date = request["date"]
|
| 233 |
+
|
| 234 |
+
try:
|
| 235 |
+
logger.info(f"手紙生成開始 - ユーザー: {user_id}, テーマ: {theme[:50]}...")
|
| 236 |
+
|
| 237 |
+
# タイムアウト付きで手紙生成を実行
|
| 238 |
+
user_history = await self.user_manager.get_user_profile(user_id)
|
| 239 |
+
|
| 240 |
+
generation_task = self.letter_generator.generate_letter(user_id, theme, user_history)
|
| 241 |
+
letter_result = await asyncio.wait_for(generation_task, timeout=self.generation_timeout)
|
| 242 |
+
|
| 243 |
+
# 生成された手紙をストレージに保存
|
| 244 |
+
await self._save_generated_letter(user_id, date, theme, letter_result)
|
| 245 |
+
|
| 246 |
+
# リクエストを完了としてマーク
|
| 247 |
+
await self.request_manager.mark_request_processed(user_id, date, "completed")
|
| 248 |
+
|
| 249 |
+
# ユーザー履歴を更新
|
| 250 |
+
await self.user_manager.update_user_history(user_id, {
|
| 251 |
+
"date": date,
|
| 252 |
+
"theme": theme,
|
| 253 |
+
"status": "completed",
|
| 254 |
+
"generated_at": datetime.now().isoformat()
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
logger.info(f"手紙生成完了 - ユーザー: {user_id}")
|
| 258 |
+
|
| 259 |
+
return {
|
| 260 |
+
"success": True,
|
| 261 |
+
"user_id": user_id,
|
| 262 |
+
"theme": theme,
|
| 263 |
+
"generation_time": letter_result["metadata"].get("generation_time", 0)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
except asyncio.TimeoutError:
|
| 267 |
+
error_msg = f"手紙生成がタイムアウトしました({self.generation_timeout}秒)"
|
| 268 |
+
logger.error(f"{error_msg} - ユーザー: {user_id}")
|
| 269 |
+
|
| 270 |
+
await self.request_manager.mark_request_failed(user_id, date, error_msg)
|
| 271 |
+
|
| 272 |
+
return {
|
| 273 |
+
"success": False,
|
| 274 |
+
"user_id": user_id,
|
| 275 |
+
"error": error_msg
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
except Exception as e:
|
| 279 |
+
error_msg = f"手紙生成中にエラーが発生: {str(e)}"
|
| 280 |
+
logger.error(f"{error_msg} - ユーザー: {user_id}\n{traceback.format_exc()}")
|
| 281 |
+
|
| 282 |
+
await self.request_manager.mark_request_failed(user_id, date, error_msg)
|
| 283 |
+
|
| 284 |
+
return {
|
| 285 |
+
"success": False,
|
| 286 |
+
"user_id": user_id,
|
| 287 |
+
"error": error_msg
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
async def _save_generated_letter(self, user_id: str, date: str, theme: str, letter_result: Dict[str, Any]) -> None:
|
| 291 |
+
"""
|
| 292 |
+
生成された手紙をストレージに保存
|
| 293 |
+
|
| 294 |
+
Args:
|
| 295 |
+
user_id: ユーザーID
|
| 296 |
+
date: 日付
|
| 297 |
+
theme: テーマ
|
| 298 |
+
letter_result: 生成結果
|
| 299 |
+
"""
|
| 300 |
+
try:
|
| 301 |
+
user_data = await self.storage_manager.get_user_data(user_id)
|
| 302 |
+
|
| 303 |
+
# 手紙データを作成
|
| 304 |
+
letter_data = {
|
| 305 |
+
"theme": theme,
|
| 306 |
+
"content": letter_result["content"],
|
| 307 |
+
"status": "completed",
|
| 308 |
+
"generated_at": datetime.now().isoformat(),
|
| 309 |
+
"metadata": letter_result["metadata"]
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
# ユーザーデータに追加
|
| 313 |
+
user_data["letters"][date] = letter_data
|
| 314 |
+
user_data["profile"]["total_letters"] = user_data["profile"].get("total_letters", 0) + 1
|
| 315 |
+
user_data["profile"]["last_request"] = date
|
| 316 |
+
|
| 317 |
+
# ストレージに保存
|
| 318 |
+
await self.storage_manager.update_user_data(user_id, user_data)
|
| 319 |
+
|
| 320 |
+
logger.info(f"手紙をストレージに保存しました - ユーザー: {user_id}, 日付: {date}")
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"手紙保存エラー - ユーザー: {user_id}, 日付: {date}: {str(e)}")
|
| 324 |
+
raise
|
| 325 |
+
|
| 326 |
+
async def _record_batch_start(self, batch_id: str, hour: int, start_time: datetime) -> None:
|
| 327 |
+
"""バッチ実行開始を記録"""
|
| 328 |
+
try:
|
| 329 |
+
system_info = await self.storage_manager.get_system_info()
|
| 330 |
+
|
| 331 |
+
if "batch_runs" not in system_info:
|
| 332 |
+
system_info["batch_runs"] = {}
|
| 333 |
+
|
| 334 |
+
system_info["batch_runs"][batch_id] = {
|
| 335 |
+
"hour": hour,
|
| 336 |
+
"start_time": start_time.isoformat(),
|
| 337 |
+
"status": "running"
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
await self.storage_manager.update_system_info(system_info)
|
| 341 |
+
|
| 342 |
+
except Exception as e:
|
| 343 |
+
logger.error(f"バッチ開始記録エラー: {str(e)}")
|
| 344 |
+
|
| 345 |
+
async def _record_batch_completion(self, batch_id: str, hour: int, start_time: datetime,
|
| 346 |
+
end_time: datetime, result: Dict[str, Any]) -> None:
|
| 347 |
+
"""バッチ実行完了を記録"""
|
| 348 |
+
try:
|
| 349 |
+
system_info = await self.storage_manager.get_system_info()
|
| 350 |
+
|
| 351 |
+
if batch_id in system_info.get("batch_runs", {}):
|
| 352 |
+
system_info["batch_runs"][batch_id].update({
|
| 353 |
+
"end_time": end_time.isoformat(),
|
| 354 |
+
"status": "completed",
|
| 355 |
+
"execution_time": (end_time - start_time).total_seconds(),
|
| 356 |
+
"processed_count": result.get("processed_count", 0),
|
| 357 |
+
"success_count": result.get("success_count", 0),
|
| 358 |
+
"failed_count": result.get("failed_count", 0),
|
| 359 |
+
"error_count": len(result.get("errors", []))
|
| 360 |
+
})
|
| 361 |
+
|
| 362 |
+
await self.storage_manager.update_system_info(system_info)
|
| 363 |
+
|
| 364 |
+
except Exception as e:
|
| 365 |
+
logger.error(f"バッチ完了記録エラー: {str(e)}")
|
| 366 |
+
|
| 367 |
+
async def _record_batch_error(self, batch_id: str, hour: int, start_time: datetime,
|
| 368 |
+
end_time: datetime, error_msg: str) -> None:
|
| 369 |
+
"""バッチ実行エラーを記録"""
|
| 370 |
+
try:
|
| 371 |
+
system_info = await self.storage_manager.get_system_info()
|
| 372 |
+
|
| 373 |
+
if batch_id in system_info.get("batch_runs", {}):
|
| 374 |
+
system_info["batch_runs"][batch_id].update({
|
| 375 |
+
"end_time": end_time.isoformat(),
|
| 376 |
+
"status": "failed",
|
| 377 |
+
"execution_time": (end_time - start_time).total_seconds(),
|
| 378 |
+
"error": error_msg
|
| 379 |
+
})
|
| 380 |
+
|
| 381 |
+
await self.storage_manager.update_system_info(system_info)
|
| 382 |
+
|
| 383 |
+
except Exception as e:
|
| 384 |
+
logger.error(f"バッチエラー記録エラー: {str(e)}")
|
| 385 |
+
|
| 386 |
+
def schedule_all_hours(self) -> None:
|
| 387 |
+
"""
|
| 388 |
+
全ての対象時刻でのスケジュール設定
|
| 389 |
+
注意: この関数は実際のスケジューリングライブラリと組み合わせて使用する
|
| 390 |
+
"""
|
| 391 |
+
logger.info("バッチスケジュールを設定します")
|
| 392 |
+
|
| 393 |
+
for hour in self.available_hours:
|
| 394 |
+
logger.info(f" - {hour}:00 に手紙生成バッチを設定")
|
| 395 |
+
|
| 396 |
+
# 実際のスケジューリングは外部ライブラリ(APScheduler等)で実装
|
| 397 |
+
# ここでは設定情報のログ出力のみ
|
| 398 |
+
logger.info("スケジュール設定完了(実際の実行は外部スケジューラーが必要)")
|
| 399 |
+
|
| 400 |
+
async def cleanup_old_data(self, days: int = 90) -> Dict[str, Any]:
|
| 401 |
+
"""
|
| 402 |
+
古いデータの自動削除
|
| 403 |
+
|
| 404 |
+
Args:
|
| 405 |
+
days: 保持日数
|
| 406 |
+
|
| 407 |
+
Returns:
|
| 408 |
+
Dict: 削除結果
|
| 409 |
+
"""
|
| 410 |
+
try:
|
| 411 |
+
logger.info(f"{days}日以前の古いデータを削除します")
|
| 412 |
+
|
| 413 |
+
# ストレージマネージャーの削除機能を使用
|
| 414 |
+
deleted_count = await self.storage_manager.cleanup_old_data(days)
|
| 415 |
+
|
| 416 |
+
# リクエストマネージャーの削除機能も使用
|
| 417 |
+
deleted_requests = await self.request_manager.cleanup_old_requests(days)
|
| 418 |
+
|
| 419 |
+
# バックアップの作成
|
| 420 |
+
backup_path = await self.storage_manager.backup_data()
|
| 421 |
+
|
| 422 |
+
result = {
|
| 423 |
+
"success": True,
|
| 424 |
+
"deleted_letters": deleted_count,
|
| 425 |
+
"deleted_requests": deleted_requests,
|
| 426 |
+
"backup_created": backup_path,
|
| 427 |
+
"cleanup_date": datetime.now().isoformat()
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
logger.info(f"古いデータ削除完了: {result}")
|
| 431 |
+
return result
|
| 432 |
+
|
| 433 |
+
except Exception as e:
|
| 434 |
+
error_msg = f"古いデータ削除中にエラーが発生: {str(e)}"
|
| 435 |
+
logger.error(f"{error_msg}\n{traceback.format_exc()}")
|
| 436 |
+
|
| 437 |
+
return {
|
| 438 |
+
"success": False,
|
| 439 |
+
"error": error_msg,
|
| 440 |
+
"cleanup_date": datetime.now().isoformat()
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
async def get_batch_statistics(self, days: int = 7) -> Dict[str, Any]:
|
| 444 |
+
"""
|
| 445 |
+
バッチ処理の統計情報を取得
|
| 446 |
+
|
| 447 |
+
Args:
|
| 448 |
+
days: 統計対象日数
|
| 449 |
+
|
| 450 |
+
Returns:
|
| 451 |
+
Dict: 統計情報
|
| 452 |
+
"""
|
| 453 |
+
try:
|
| 454 |
+
system_info = await self.storage_manager.get_system_info()
|
| 455 |
+
batch_runs = system_info.get("batch_runs", {})
|
| 456 |
+
|
| 457 |
+
# 指定日数以内のバッチ実行を抽出
|
| 458 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 459 |
+
recent_batches = []
|
| 460 |
+
|
| 461 |
+
for batch_id, batch_info in batch_runs.items():
|
| 462 |
+
try:
|
| 463 |
+
start_time = datetime.fromisoformat(batch_info["start_time"])
|
| 464 |
+
if start_time >= cutoff_date:
|
| 465 |
+
recent_batches.append(batch_info)
|
| 466 |
+
except (ValueError, KeyError):
|
| 467 |
+
continue
|
| 468 |
+
|
| 469 |
+
# 統計を計算
|
| 470 |
+
total_runs = len(recent_batches)
|
| 471 |
+
successful_runs = len([b for b in recent_batches if b.get("status") == "completed"])
|
| 472 |
+
failed_runs = len([b for b in recent_batches if b.get("status") == "failed"])
|
| 473 |
+
|
| 474 |
+
total_processed = sum(b.get("processed_count", 0) for b in recent_batches)
|
| 475 |
+
total_success = sum(b.get("success_count", 0) for b in recent_batches)
|
| 476 |
+
total_failed = sum(b.get("failed_count", 0) for b in recent_batches)
|
| 477 |
+
|
| 478 |
+
avg_execution_time = 0
|
| 479 |
+
if recent_batches:
|
| 480 |
+
execution_times = [b.get("execution_time", 0) for b in recent_batches if b.get("execution_time")]
|
| 481 |
+
if execution_times:
|
| 482 |
+
avg_execution_time = sum(execution_times) / len(execution_times)
|
| 483 |
+
|
| 484 |
+
# 時刻別統計
|
| 485 |
+
hourly_stats = {hour: {"runs": 0, "processed": 0, "success": 0} for hour in self.available_hours}
|
| 486 |
+
|
| 487 |
+
for batch in recent_batches:
|
| 488 |
+
hour = batch.get("hour")
|
| 489 |
+
if hour in hourly_stats:
|
| 490 |
+
hourly_stats[hour]["runs"] += 1
|
| 491 |
+
hourly_stats[hour]["processed"] += batch.get("processed_count", 0)
|
| 492 |
+
hourly_stats[hour]["success"] += batch.get("success_count", 0)
|
| 493 |
+
|
| 494 |
+
return {
|
| 495 |
+
"period_days": days,
|
| 496 |
+
"total_runs": total_runs,
|
| 497 |
+
"successful_runs": successful_runs,
|
| 498 |
+
"failed_runs": failed_runs,
|
| 499 |
+
"success_rate": (successful_runs / total_runs * 100) if total_runs > 0 else 0,
|
| 500 |
+
"total_processed": total_processed,
|
| 501 |
+
"total_success": total_success,
|
| 502 |
+
"total_failed": total_failed,
|
| 503 |
+
"processing_success_rate": (total_success / total_processed * 100) if total_processed > 0 else 0,
|
| 504 |
+
"avg_execution_time": avg_execution_time,
|
| 505 |
+
"hourly_stats": hourly_stats,
|
| 506 |
+
"generated_at": datetime.now().isoformat()
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
except Exception as e:
|
| 510 |
+
logger.error(f"統計情報取得エラー: {str(e)}")
|
| 511 |
+
return {"error": str(e)}
|
| 512 |
+
|
| 513 |
+
async def test_batch_processing(self, test_hour: int = 2) -> Dict[str, Any]:
|
| 514 |
+
"""
|
| 515 |
+
バッチ処理のテスト実行
|
| 516 |
+
|
| 517 |
+
Args:
|
| 518 |
+
test_hour: テスト対象の時刻
|
| 519 |
+
|
| 520 |
+
Returns:
|
| 521 |
+
Dict: テスト結果
|
| 522 |
+
"""
|
| 523 |
+
try:
|
| 524 |
+
logger.info(f"バッチ処理テストを開始します({test_hour}時)")
|
| 525 |
+
|
| 526 |
+
# API接続テスト
|
| 527 |
+
api_status = await self.letter_generator.check_api_connections()
|
| 528 |
+
if not api_status.get("overall", False):
|
| 529 |
+
return {
|
| 530 |
+
"success": False,
|
| 531 |
+
"error": "API接続テストに失敗しました",
|
| 532 |
+
"api_status": api_status
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
# テスト用のバッチ処理を実行
|
| 536 |
+
result = await self.run_hourly_batch(test_hour)
|
| 537 |
+
|
| 538 |
+
return {
|
| 539 |
+
"success": True,
|
| 540 |
+
"message": "バッチ処理テスト完了",
|
| 541 |
+
"api_status": api_status,
|
| 542 |
+
"batch_result": result
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
except Exception as e:
|
| 546 |
+
return {
|
| 547 |
+
"success": False,
|
| 548 |
+
"error": f"バッチ処理テスト中にエラーが発生: {str(e)}"
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
# テスト用の関数
|
| 553 |
+
async def test_batch_scheduler():
|
| 554 |
+
"""BatchSchedulerのテスト"""
|
| 555 |
+
import tempfile
|
| 556 |
+
import uuid
|
| 557 |
+
|
| 558 |
+
# 一時ディレクトリでテスト
|
| 559 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 560 |
+
test_file = os.path.join(temp_dir, "test_letters.json")
|
| 561 |
+
storage = AsyncStorageManager(test_file)
|
| 562 |
+
scheduler = BatchScheduler(storage)
|
| 563 |
+
|
| 564 |
+
print("=== BatchSchedulerテスト開始 ===")
|
| 565 |
+
|
| 566 |
+
# テスト用のリクエストを作成
|
| 567 |
+
user_id = str(uuid.uuid4())
|
| 568 |
+
success, message = await scheduler.request_manager.submit_request(user_id, "テストテーマ", 2)
|
| 569 |
+
print(f"✓ テストリクエスト作成: {success} - {message}")
|
| 570 |
+
|
| 571 |
+
# バッチ処理テスト
|
| 572 |
+
result = await scheduler.run_hourly_batch(2)
|
| 573 |
+
print(f"✓ バッチ処理テスト: {result['success']}")
|
| 574 |
+
|
| 575 |
+
# 統計情報テスト
|
| 576 |
+
stats = await scheduler.get_batch_statistics()
|
| 577 |
+
print(f"✓ 統計情報取得テスト: {stats}")
|
| 578 |
+
|
| 579 |
+
# 古いデータ削除テスト
|
| 580 |
+
cleanup_result = await scheduler.cleanup_old_data(0) # 全て削除
|
| 581 |
+
print(f"✓ データ削除テスト: {cleanup_result['success']}")
|
| 582 |
+
|
| 583 |
+
print("=== 全てのテストが完了しました! ===")
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
if __name__ == "__main__":
|
| 587 |
+
asyncio.run(test_batch_scheduler())
|
bijyutukann-yoru.jpg
ADDED
|
Git LFS Details
|
components_chat_interface.py
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
チャットインターフェースコンポーネント
|
| 3 |
+
Streamlitのチャット機能を使用したメッセージ表示と入力処理
|
| 4 |
+
マスクアイコンとフリップアニメーション機能を含む
|
| 5 |
+
"""
|
| 6 |
+
import streamlit as st
|
| 7 |
+
import logging
|
| 8 |
+
import re
|
| 9 |
+
import uuid
|
| 10 |
+
from typing import List, Dict, Optional, Tuple
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class ChatInterface:
|
| 16 |
+
"""チャットインターフェースを管理するクラス"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, max_input_length: int = 1000):
|
| 19 |
+
"""
|
| 20 |
+
Args:
|
| 21 |
+
max_input_length: 入力メッセージの最大長
|
| 22 |
+
"""
|
| 23 |
+
self.max_input_length = max_input_length
|
| 24 |
+
|
| 25 |
+
def render_chat_history(self, messages: List[Dict[str, str]],
|
| 26 |
+
memory_summary: str = "") -> None:
|
| 27 |
+
"""
|
| 28 |
+
チャット履歴を表示する(マスク機能付き、最適化版)
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
messages: チャットメッセージのリスト
|
| 32 |
+
memory_summary: メモリサマリー(重要単語から生成)
|
| 33 |
+
"""
|
| 34 |
+
logger.info(f"🎯 render_chat_history 開始: {len(messages) if messages else 0}件のメッセージ")
|
| 35 |
+
try:
|
| 36 |
+
# 初期メッセージの存在確認と復元
|
| 37 |
+
if messages:
|
| 38 |
+
initial_messages = [msg for msg in messages if msg.get('is_initial', False)]
|
| 39 |
+
if not initial_messages:
|
| 40 |
+
logger.warning("初期メッセージが見つかりません - 復元を試行")
|
| 41 |
+
# 初期メッセージが存在しない場合は先頭に追加
|
| 42 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 43 |
+
messages.insert(0, initial_message)
|
| 44 |
+
logger.info("初期メッセージを復元しました")
|
| 45 |
+
|
| 46 |
+
# 履歴表示の重複実行を防ぐ(改良版)
|
| 47 |
+
# セッション固有のレンダリング状態を管理
|
| 48 |
+
if 'chat_render_state' not in st.session_state:
|
| 49 |
+
st.session_state.chat_render_state = {
|
| 50 |
+
'last_messages_hash': None,
|
| 51 |
+
'render_count': 0,
|
| 52 |
+
'last_render_time': 0
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# メッセージ内容とポチモード状態を含むハッシュを生成
|
| 56 |
+
show_all_hidden = st.session_state.get('show_all_hidden', False)
|
| 57 |
+
messages_with_state = {
|
| 58 |
+
'messages': [{'role': msg.get('role'), 'content': msg.get('content', '')[:100]} for msg in messages], # 内容を短縮してハッシュ計算を軽量化
|
| 59 |
+
'show_all_hidden': show_all_hidden,
|
| 60 |
+
'message_count': len(messages)
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
import time
|
| 64 |
+
current_time = time.time()
|
| 65 |
+
messages_hash = hash(str(messages_with_state))
|
| 66 |
+
last_render_hash = st.session_state.chat_render_state['last_messages_hash']
|
| 67 |
+
|
| 68 |
+
# 短時間での連続レンダリングを防止(0.5秒以内の再レンダリングを制限)
|
| 69 |
+
time_since_last_render = current_time - st.session_state.chat_render_state['last_render_time']
|
| 70 |
+
if time_since_last_render < 0.5 and last_render_hash == messages_hash:
|
| 71 |
+
logger.debug(f"短時間での重複レンダリングをスキップ({time_since_last_render:.2f}秒前)")
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
# 強制表示条件をより厳密に制御
|
| 75 |
+
force_render_conditions = [
|
| 76 |
+
st.session_state.get('tutorial_start_requested', False),
|
| 77 |
+
st.session_state.get('tutorial_skip_requested', False),
|
| 78 |
+
st.session_state.get('show_all_hidden_changed', False),
|
| 79 |
+
st.session_state.get('force_chat_rerender', False) # 明示的な強制レンダリングフラグ
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
should_force_render = any(force_render_conditions)
|
| 83 |
+
|
| 84 |
+
# ハッシュが同じで強制表示条件もない場合はスキップ
|
| 85 |
+
if last_render_hash == messages_hash and not should_force_render:
|
| 86 |
+
logger.debug("チャット履歴表示をスキップ(変更なし)")
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
# レンダリング実行(ログ削除で軽量化)
|
| 90 |
+
|
| 91 |
+
# レンダリング状態を更新
|
| 92 |
+
st.session_state.chat_render_state['last_messages_hash'] = messages_hash
|
| 93 |
+
st.session_state.chat_render_state['render_count'] += 1
|
| 94 |
+
st.session_state.chat_render_state['last_render_time'] = current_time
|
| 95 |
+
|
| 96 |
+
# メモリサマリーがある場合は表示
|
| 97 |
+
if memory_summary:
|
| 98 |
+
with st.expander("💭 過去の会話の記憶", expanded=False):
|
| 99 |
+
st.info(memory_summary)
|
| 100 |
+
|
| 101 |
+
# 独自のチャット表示(st.chat_messageを使わない安定版)
|
| 102 |
+
if not messages:
|
| 103 |
+
st.info("まだメッセージがありません。下のチャット欄で麻理に話しかけてみてください。")
|
| 104 |
+
return
|
| 105 |
+
|
| 106 |
+
for i, message in enumerate(messages):
|
| 107 |
+
role = message.get("role", "user")
|
| 108 |
+
content = message.get("content", "")
|
| 109 |
+
timestamp = message.get("timestamp")
|
| 110 |
+
is_initial = message.get("is_initial", False)
|
| 111 |
+
message_id = message.get("message_id", f"msg_{i}")
|
| 112 |
+
|
| 113 |
+
# 独自のチャットバブル表示
|
| 114 |
+
self._render_custom_chat_bubble(role, content, is_initial, message_id, timestamp)
|
| 115 |
+
|
| 116 |
+
# 履歴表示完了をマーク
|
| 117 |
+
# 既にレンダリング状態は上で更新済み
|
| 118 |
+
|
| 119 |
+
logger.debug(f"チャット履歴表示完了({len(messages)}件)")
|
| 120 |
+
|
| 121 |
+
# 強制表示フラグをクリア(表示完了後に実行)
|
| 122 |
+
if st.session_state.get('show_all_hidden_changed', False):
|
| 123 |
+
st.session_state.show_all_hidden_changed = False
|
| 124 |
+
logger.info("犬のボタン状態変更フラグをクリアしました")
|
| 125 |
+
|
| 126 |
+
if st.session_state.get('force_chat_rerender', False):
|
| 127 |
+
st.session_state.force_chat_rerender = False
|
| 128 |
+
logger.info("強制チャット再レンダリングフラグをクリアしました")
|
| 129 |
+
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"チャット履歴表示エラー: {e}")
|
| 132 |
+
st.error("チャット履歴の表示中にエラーが発生しました。")
|
| 133 |
+
|
| 134 |
+
def _render_custom_chat_bubble(self, role: str, content: str, is_initial: bool, message_id: str, timestamp: str = None):
|
| 135 |
+
"""独自のチャットバブル表示(st.chat_messageを使わない安定版)"""
|
| 136 |
+
logger.info(f"🎨 カスタムチャットバブル開始: {role} - '{content[:30]}...' - 初期:{is_initial}")
|
| 137 |
+
try:
|
| 138 |
+
# チャットバブルのCSS
|
| 139 |
+
bubble_css = """
|
| 140 |
+
<style>
|
| 141 |
+
.custom-chat-container {
|
| 142 |
+
margin: 10px 0;
|
| 143 |
+
display: flex;
|
| 144 |
+
flex-direction: column;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.custom-chat-bubble {
|
| 148 |
+
max-width: 80%;
|
| 149 |
+
padding: 12px 16px;
|
| 150 |
+
border-radius: 18px;
|
| 151 |
+
margin: 4px 0;
|
| 152 |
+
word-wrap: break-word;
|
| 153 |
+
line-height: 1.5;
|
| 154 |
+
font-size: 18px;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.user-bubble {
|
| 158 |
+
background: #007bff;
|
| 159 |
+
color: white;
|
| 160 |
+
align-self: flex-end;
|
| 161 |
+
margin-left: auto;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.assistant-bubble {
|
| 165 |
+
background: #f1f3f4;
|
| 166 |
+
color: #333;
|
| 167 |
+
align-self: flex-start;
|
| 168 |
+
margin-right: auto;
|
| 169 |
+
border: 1px solid #e0e0e0;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.initial-message-bubble {
|
| 173 |
+
background: #e8f5e8 !important;
|
| 174 |
+
color: #2d5a2d !important;
|
| 175 |
+
font-weight: 500 !important;
|
| 176 |
+
border: 2px solid #4caf50 !important;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.chat-avatar {
|
| 180 |
+
width: 32px;
|
| 181 |
+
height: 32px;
|
| 182 |
+
border-radius: 50%;
|
| 183 |
+
margin: 0 8px;
|
| 184 |
+
display: flex;
|
| 185 |
+
align-items: center;
|
| 186 |
+
justify-content: center;
|
| 187 |
+
font-size: 16px;
|
| 188 |
+
flex-shrink: 0;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.user-avatar {
|
| 192 |
+
background: #007bff;
|
| 193 |
+
color: white;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.assistant-avatar {
|
| 197 |
+
background: #ff69b4;
|
| 198 |
+
color: white;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.chat-row {
|
| 202 |
+
display: flex;
|
| 203 |
+
align-items: flex-start;
|
| 204 |
+
margin: 8px 0;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.user-row {
|
| 208 |
+
flex-direction: row-reverse;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.assistant-row {
|
| 212 |
+
flex-direction: row;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.timestamp {
|
| 216 |
+
font-size: 0.8em;
|
| 217 |
+
color: #666;
|
| 218 |
+
margin-top: 4px;
|
| 219 |
+
text-align: center;
|
| 220 |
+
}
|
| 221 |
+
</style>
|
| 222 |
+
"""
|
| 223 |
+
|
| 224 |
+
st.markdown(bubble_css, unsafe_allow_html=True)
|
| 225 |
+
|
| 226 |
+
# アバターとバブルのスタイル決定
|
| 227 |
+
if role == "user":
|
| 228 |
+
avatar_class = "user-avatar"
|
| 229 |
+
bubble_class = "user-bubble"
|
| 230 |
+
row_class = "user-row"
|
| 231 |
+
avatar_icon = "👤"
|
| 232 |
+
else:
|
| 233 |
+
avatar_class = "assistant-avatar"
|
| 234 |
+
bubble_class = "assistant-bubble"
|
| 235 |
+
row_class = "assistant-row"
|
| 236 |
+
avatar_icon = "🤖"
|
| 237 |
+
|
| 238 |
+
# 初期メッセージの場合は特別なスタイル
|
| 239 |
+
if is_initial:
|
| 240 |
+
bubble_class += " initial-message-bubble"
|
| 241 |
+
avatar_icon = "💬"
|
| 242 |
+
|
| 243 |
+
# コンテンツのHTMLエスケープ処理(HTMLタグとStreamlitクラス名を完全に除去)
|
| 244 |
+
import html
|
| 245 |
+
import re
|
| 246 |
+
|
| 247 |
+
# HTMLタグとStreamlitの内部クラス名を完全に除去
|
| 248 |
+
# 1. HTMLタグを除去(開始・終了タグ両方)
|
| 249 |
+
clean_content = re.sub(r'<[^>]*>', '', content)
|
| 250 |
+
|
| 251 |
+
# 2. Streamlitの内部クラス名を除去(より包括的)
|
| 252 |
+
clean_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_content)
|
| 253 |
+
clean_content = re.sub(r'class="[^"]*st-emotion-cache[^"]*"', '', clean_content)
|
| 254 |
+
clean_content = re.sub(r'class="[^"]*st-[^"]*"', '', clean_content)
|
| 255 |
+
clean_content = re.sub(r"class='[^']*st-emotion-cache[^']*'", '', clean_content)
|
| 256 |
+
clean_content = re.sub(r"class='[^']*st-[^']*'", '', clean_content)
|
| 257 |
+
|
| 258 |
+
# 3. HTML属性を除去
|
| 259 |
+
clean_content = re.sub(r'data-testid="[^"]*"', '', clean_content)
|
| 260 |
+
clean_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_content)
|
| 261 |
+
clean_content = re.sub(r'class="[^"]*"', '', clean_content)
|
| 262 |
+
clean_content = re.sub(r"class='[^']*'", '', clean_content)
|
| 263 |
+
clean_content = re.sub(r'id="[^"]*"', '', clean_content)
|
| 264 |
+
clean_content = re.sub(r"id='[^']*'", '', clean_content)
|
| 265 |
+
|
| 266 |
+
# 4. その他のHTML関連文字列を除去
|
| 267 |
+
clean_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_content) # HTMLエンティティ
|
| 268 |
+
clean_content = re.sub(r'[<>]', '', clean_content) # 残った角括弧
|
| 269 |
+
|
| 270 |
+
# 5. 余分な空白を除去
|
| 271 |
+
clean_content = re.sub(r'\s+', ' ', clean_content).strip()
|
| 272 |
+
|
| 273 |
+
# 6. HTMLエスケープ
|
| 274 |
+
escaped_content = html.escape(clean_content)
|
| 275 |
+
|
| 276 |
+
if content != clean_content:
|
| 277 |
+
logger.error(f"🚨 HTMLタグ混入検出! 元の内容: '{content}'")
|
| 278 |
+
logger.error(f"🚨 クリーン後: '{clean_content}'")
|
| 279 |
+
# スタックトレースを出力して呼び出し元を特定
|
| 280 |
+
import traceback
|
| 281 |
+
logger.error(f"🚨 呼び出しスタック: {traceback.format_stack()}")
|
| 282 |
+
else:
|
| 283 |
+
logger.debug(f"通常テキストをHTMLエスケープ: '{content[:30]}...'")
|
| 284 |
+
|
| 285 |
+
# チャットバブルのHTML生成
|
| 286 |
+
chat_html = f"""
|
| 287 |
+
<div class="custom-chat-container">
|
| 288 |
+
<div class="chat-row {row_class}">
|
| 289 |
+
<div class="chat-avatar {avatar_class}">
|
| 290 |
+
{avatar_icon}
|
| 291 |
+
</div>
|
| 292 |
+
<div class="custom-chat-bubble {bubble_class}">
|
| 293 |
+
{escaped_content}
|
| 294 |
+
{f'<div class="timestamp">{html.escape(timestamp)}</div>' if timestamp and st.session_state.get("debug_mode", False) else ''}
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
st.markdown(chat_html, unsafe_allow_html=True)
|
| 301 |
+
|
| 302 |
+
# 麻理のメッセージで隠された真実がある場合の処理
|
| 303 |
+
if role == "assistant" and not is_initial:
|
| 304 |
+
has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content)
|
| 305 |
+
if has_hidden_content:
|
| 306 |
+
# 犬のボタンの状態に応じて表示を切り替え
|
| 307 |
+
show_all_hidden = st.session_state.get('show_all_hidden', False)
|
| 308 |
+
logger.debug(f"隠された真実の表示判定: show_all_hidden={show_all_hidden}, has_hidden={has_hidden_content}")
|
| 309 |
+
|
| 310 |
+
if show_all_hidden:
|
| 311 |
+
# 本音表示モードの場合は隠された内容を表示
|
| 312 |
+
# HTMLタグとStreamlitクラス名を除去してからエスケープ
|
| 313 |
+
clean_hidden_content = re.sub(r'<[^>]*>', '', hidden_content)
|
| 314 |
+
clean_hidden_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_hidden_content)
|
| 315 |
+
clean_hidden_content = re.sub(r'class="[^"]*"', '', clean_hidden_content)
|
| 316 |
+
clean_hidden_content = re.sub(r"class='[^']*'", '', clean_hidden_content)
|
| 317 |
+
clean_hidden_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_hidden_content)
|
| 318 |
+
clean_hidden_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_hidden_content)
|
| 319 |
+
clean_hidden_content = re.sub(r'[<>]', '', clean_hidden_content)
|
| 320 |
+
clean_hidden_content = re.sub(r'\s+', ' ', clean_hidden_content).strip()
|
| 321 |
+
escaped_hidden_content = html.escape(clean_hidden_content)
|
| 322 |
+
|
| 323 |
+
hidden_html = f"""
|
| 324 |
+
<div class="custom-chat-container">
|
| 325 |
+
<div class="chat-row assistant-row">
|
| 326 |
+
<div class="chat-avatar assistant-avatar">
|
| 327 |
+
🐕
|
| 328 |
+
</div>
|
| 329 |
+
<div class="custom-chat-bubble assistant-bubble" style="background: #fff8e1 !important; border: 2px solid #ffc107 !important;">
|
| 330 |
+
<strong>🐕 ポチの本音翻訳:</strong><br>
|
| 331 |
+
{escaped_hidden_content}
|
| 332 |
+
</div>
|
| 333 |
+
</div>
|
| 334 |
+
</div>
|
| 335 |
+
"""
|
| 336 |
+
st.markdown(hidden_html, unsafe_allow_html=True)
|
| 337 |
+
logger.debug(f"隠された真実を表示: '{clean_hidden_content[:30]}...'")
|
| 338 |
+
else:
|
| 339 |
+
logger.debug("通常モードのため隠された真実は非表示")
|
| 340 |
+
|
| 341 |
+
logger.debug(f"カスタムチャットバブル表示完了: {role} - {message_id}")
|
| 342 |
+
|
| 343 |
+
except Exception as e:
|
| 344 |
+
logger.error(f"カスタムチャットバブル表示エラー: {e}")
|
| 345 |
+
logger.error(f"エラー詳細: role={role}, content_len={len(content)}, is_initial={is_initial}, message_id={message_id}")
|
| 346 |
+
import traceback
|
| 347 |
+
logger.error(f"スタックトレース: {traceback.format_exc()}")
|
| 348 |
+
# フォールバック: シンプルなテキスト表示
|
| 349 |
+
st.markdown(f"**{role}**: {content}")
|
| 350 |
+
logger.info("フォールバック表示を実行しました")
|
| 351 |
+
|
| 352 |
+
def _render_mari_message_with_mask(self, message_id: str, content: str, is_initial: bool = False) -> None:
|
| 353 |
+
"""
|
| 354 |
+
麻理のメッセージをマスク機能付きで表示する(廃止予定)
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
message_id: メッセージの一意ID
|
| 358 |
+
content: メッセージ内容
|
| 359 |
+
is_initial: 初期メッセージかどうか
|
| 360 |
+
"""
|
| 361 |
+
logger.warning("⚠️ 廃止予定のメソッドが呼ばれました: _render_mari_message_with_mask")
|
| 362 |
+
# カスタムチャットバブルに移行
|
| 363 |
+
self._render_custom_chat_bubble("assistant", content, is_initial, message_id)
|
| 364 |
+
return
|
| 365 |
+
|
| 366 |
+
try:
|
| 367 |
+
# メッセージ処理キャッシュをチェック(重複処理防止)
|
| 368 |
+
cache_key = f"processed_{message_id}_{hash(content)}"
|
| 369 |
+
if cache_key in st.session_state:
|
| 370 |
+
# キャッシュから結果を取得
|
| 371 |
+
cached_result = st.session_state[cache_key]
|
| 372 |
+
has_hidden_content = cached_result['has_hidden']
|
| 373 |
+
visible_content = cached_result['visible_content']
|
| 374 |
+
hidden_content = cached_result['hidden_content']
|
| 375 |
+
logger.debug(f"キャッシュからメッセージ処理結果を取得: {message_id}")
|
| 376 |
+
else:
|
| 377 |
+
# 隠された真実を検出
|
| 378 |
+
has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content)
|
| 379 |
+
|
| 380 |
+
# 結果をキャッシュに保存
|
| 381 |
+
st.session_state[cache_key] = {
|
| 382 |
+
'has_hidden': has_hidden_content,
|
| 383 |
+
'visible_content': visible_content,
|
| 384 |
+
'hidden_content': hidden_content
|
| 385 |
+
}
|
| 386 |
+
logger.debug(f"メッセージ処理結果をキャッシュに保存: {message_id}")
|
| 387 |
+
|
| 388 |
+
# 隠された真実が検出されない場合のフォールバック処理
|
| 389 |
+
if not has_hidden_content:
|
| 390 |
+
logger.warning(f"隠された真実が検出されませんでした: '{content[:50]}...'")
|
| 391 |
+
# AIが[HIDDEN:...]形式で応答していない場合は通常表示
|
| 392 |
+
|
| 393 |
+
# セッション状態でフリップ状態を管理
|
| 394 |
+
if 'message_flip_states' not in st.session_state:
|
| 395 |
+
st.session_state.message_flip_states = {}
|
| 396 |
+
|
| 397 |
+
is_flipped = st.session_state.message_flip_states.get(message_id, False)
|
| 398 |
+
|
| 399 |
+
if has_hidden_content:
|
| 400 |
+
# マスクアイコン付きメッセージを表示
|
| 401 |
+
self._render_message_with_flip_animation(
|
| 402 |
+
message_id, visible_content, hidden_content, is_flipped, is_initial
|
| 403 |
+
)
|
| 404 |
+
else:
|
| 405 |
+
# 通常のメッセージ表示
|
| 406 |
+
if is_initial:
|
| 407 |
+
# 初期メッセージは確実に黒文字で表示(強制スタイル適用)
|
| 408 |
+
initial_message_html = f'''
|
| 409 |
+
<div class="mari-initial-message" style="
|
| 410 |
+
color: #333333 !important;
|
| 411 |
+
font-weight: 500 !important;
|
| 412 |
+
background: #f5f5f5 !important;
|
| 413 |
+
padding: 15px !important;
|
| 414 |
+
border-radius: 12px !important;
|
| 415 |
+
border: 1px solid rgba(0,0,0,0.1) !important;
|
| 416 |
+
margin: 8px 0 !important;
|
| 417 |
+
font-family: var(--mari-font) !important;
|
| 418 |
+
line-height: 1.7 !important;
|
| 419 |
+
">
|
| 420 |
+
{content}
|
| 421 |
+
</div>
|
| 422 |
+
'''
|
| 423 |
+
st.markdown(initial_message_html, unsafe_allow_html=True)
|
| 424 |
+
logger.debug(f"初期メッセージを表示: '{content}'")
|
| 425 |
+
else:
|
| 426 |
+
st.markdown(content)
|
| 427 |
+
|
| 428 |
+
except Exception as e:
|
| 429 |
+
logger.error(f"マスク付きメッセージ表示エラー: {e}")
|
| 430 |
+
# フォールバック: 通常のメッセージ表示
|
| 431 |
+
st.markdown(content)
|
| 432 |
+
|
| 433 |
+
def _detect_hidden_content(self, content: str) -> Tuple[bool, str, str]:
|
| 434 |
+
"""
|
| 435 |
+
メッセージから隠された真実を検出する
|
| 436 |
+
|
| 437 |
+
Args:
|
| 438 |
+
content: メッセージ内容
|
| 439 |
+
|
| 440 |
+
Returns:
|
| 441 |
+
(隠された内容があるか, 表示用内容, 隠された内容)
|
| 442 |
+
"""
|
| 443 |
+
try:
|
| 444 |
+
# デバッグ用ログ(重複実行防止)
|
| 445 |
+
logger.debug(f"🔍 隠された内容検出中: '{content[:50]}...'")
|
| 446 |
+
|
| 447 |
+
# 隠された真実のマーカーを検索
|
| 448 |
+
# 形式: [HIDDEN:隠された内容]表示される内容
|
| 449 |
+
hidden_pattern = r'\[HIDDEN:(.*?)\](.*)'
|
| 450 |
+
match = re.search(hidden_pattern, content)
|
| 451 |
+
|
| 452 |
+
if match:
|
| 453 |
+
hidden_content = match.group(1).strip()
|
| 454 |
+
visible_content = match.group(2).strip()
|
| 455 |
+
|
| 456 |
+
# 複数HIDDENをチェック
|
| 457 |
+
additional_hidden = re.findall(r'\[HIDDEN:(.*?)\]', visible_content)
|
| 458 |
+
if additional_hidden:
|
| 459 |
+
logger.warning(f"⚠️ 複数HIDDEN検出: {len(additional_hidden) + 1}個のHIDDENが見つかりました")
|
| 460 |
+
# 2番目以降のHIDDENを表示内容から除去
|
| 461 |
+
visible_content = re.sub(r'\[HIDDEN:.*?\]', '', visible_content).strip()
|
| 462 |
+
logger.info(f"🔧 複数HIDDEN除去後: 表示='{visible_content}'")
|
| 463 |
+
|
| 464 |
+
logger.info(f"🐕 隠された真実を検出: 表示='{visible_content}', 隠し='{hidden_content}'")
|
| 465 |
+
return True, visible_content, hidden_content
|
| 466 |
+
|
| 467 |
+
# マーカーがない場合は通常のメッセージ
|
| 468 |
+
logger.debug(f"📝 通常メッセージ: '{content[:30]}...'")
|
| 469 |
+
return False, content, ""
|
| 470 |
+
|
| 471 |
+
except Exception as e:
|
| 472 |
+
logger.error(f"隠された内容検出エラー: {e}")
|
| 473 |
+
return False, content, ""
|
| 474 |
+
|
| 475 |
+
def _render_message_with_flip_animation(self, message_id: str, visible_content: str,
|
| 476 |
+
hidden_content: str, is_flipped: bool, is_initial: bool = False) -> None:
|
| 477 |
+
"""
|
| 478 |
+
フリップアニメーション付きメッセージを表示する
|
| 479 |
+
|
| 480 |
+
Args:
|
| 481 |
+
message_id: メッセージID
|
| 482 |
+
visible_content: 表示用内容
|
| 483 |
+
hidden_content: 隠された内容
|
| 484 |
+
is_flipped: 現在フリップされているか
|
| 485 |
+
is_initial: 初期メッセージかどうか
|
| 486 |
+
"""
|
| 487 |
+
try:
|
| 488 |
+
logger.info(f"🐕 ポチモード付きメッセージを表示: ID={message_id}, フリップ={is_flipped}")
|
| 489 |
+
# フリップアニメーション用CSS
|
| 490 |
+
flip_css = f"""
|
| 491 |
+
<style>
|
| 492 |
+
.message-container-{message_id} {{
|
| 493 |
+
position: relative;
|
| 494 |
+
perspective: 1000px;
|
| 495 |
+
margin: 10px 0;
|
| 496 |
+
}}
|
| 497 |
+
|
| 498 |
+
.message-flip-{message_id} {{
|
| 499 |
+
position: relative;
|
| 500 |
+
width: 100%;
|
| 501 |
+
height: auto;
|
| 502 |
+
min-height: 60px;
|
| 503 |
+
transform-style: preserve-3d;
|
| 504 |
+
transition: transform 0.4s ease-in-out;
|
| 505 |
+
transform: {'rotateY(180deg)' if is_flipped else 'rotateY(0deg)'};
|
| 506 |
+
}}
|
| 507 |
+
|
| 508 |
+
.message-side-{message_id} {{
|
| 509 |
+
position: absolute;
|
| 510 |
+
width: 100%;
|
| 511 |
+
backface-visibility: hidden;
|
| 512 |
+
padding: 15px 45px 15px 15px;
|
| 513 |
+
border-radius: 12px;
|
| 514 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 515 |
+
font-family: var(--mari-font);
|
| 516 |
+
line-height: 1.7;
|
| 517 |
+
min-height: 50px;
|
| 518 |
+
}}
|
| 519 |
+
|
| 520 |
+
.message-front-{message_id} {{
|
| 521 |
+
background: var(--mari-bubble-bg);
|
| 522 |
+
border: 1px solid rgba(0, 0, 0, 0.1);
|
| 523 |
+
color: var(--text-color);
|
| 524 |
+
transform: rotateY(0deg);
|
| 525 |
+
}}
|
| 526 |
+
|
| 527 |
+
.message-back-{message_id} {{
|
| 528 |
+
background: var(--hidden-bubble-bg);
|
| 529 |
+
border: 1px solid rgba(255, 248, 225, 0.7);
|
| 530 |
+
color: var(--text-color);
|
| 531 |
+
transform: rotateY(180deg);
|
| 532 |
+
box-shadow: 0 2px 8px rgba(255, 248, 225, 0.3);
|
| 533 |
+
}}
|
| 534 |
+
|
| 535 |
+
.mask-icon-{message_id} {{
|
| 536 |
+
position: absolute;
|
| 537 |
+
bottom: 12px;
|
| 538 |
+
right: 12px;
|
| 539 |
+
font-size: 20px;
|
| 540 |
+
cursor: pointer;
|
| 541 |
+
padding: 6px;
|
| 542 |
+
border-radius: 50%;
|
| 543 |
+
background: rgba(255, 255, 255, 0.9);
|
| 544 |
+
transition: all 0.3s ease;
|
| 545 |
+
z-index: 10;
|
| 546 |
+
user-select: none;
|
| 547 |
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
| 548 |
+
}}
|
| 549 |
+
|
| 550 |
+
.mask-icon-{message_id}:hover {{
|
| 551 |
+
background: rgba(255, 255, 255, 1);
|
| 552 |
+
transform: scale(1.1);
|
| 553 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
| 554 |
+
}}
|
| 555 |
+
|
| 556 |
+
.mask-icon-{message_id}:active {{
|
| 557 |
+
transform: scale(0.95);
|
| 558 |
+
}}
|
| 559 |
+
|
| 560 |
+
.tutorial-pulse-{message_id} {{
|
| 561 |
+
animation: tutorialPulse 2s ease-in-out infinite;
|
| 562 |
+
}}
|
| 563 |
+
|
| 564 |
+
@keyframes tutorialPulse {{
|
| 565 |
+
0%, 100% {{
|
| 566 |
+
transform: scale(1);
|
| 567 |
+
box-shadow: 0 0 0 0 rgba(255, 105, 180, 0.7);
|
| 568 |
+
}}
|
| 569 |
+
50% {{
|
| 570 |
+
transform: scale(1.1);
|
| 571 |
+
box-shadow: 0 0 0 10px rgba(255, 105, 180, 0);
|
| 572 |
+
}}
|
| 573 |
+
}}
|
| 574 |
+
</style>
|
| 575 |
+
"""
|
| 576 |
+
|
| 577 |
+
# 犬のボタンの状態を事前にチェックして即座に反映(無限ループ防止)
|
| 578 |
+
show_all_hidden = st.session_state.get('show_all_hidden', False)
|
| 579 |
+
|
| 580 |
+
# 犬のボタンの状態に従って表示を切り替え(状態変更時のみ)
|
| 581 |
+
current_flip_state = st.session_state.message_flip_states.get(message_id, False)
|
| 582 |
+
if show_all_hidden != current_flip_state:
|
| 583 |
+
st.session_state.message_flip_states[message_id] = show_all_hidden
|
| 584 |
+
is_flipped = show_all_hidden
|
| 585 |
+
logger.debug(f"メッセージ {message_id} のフリップ状態を更新: {is_flipped}")
|
| 586 |
+
else:
|
| 587 |
+
is_flipped = current_flip_state
|
| 588 |
+
|
| 589 |
+
# 現在表示するコンテンツを決定
|
| 590 |
+
current_content = hidden_content if is_flipped else visible_content
|
| 591 |
+
|
| 592 |
+
# 初期メッセージの場合は確実に黒文字で表示
|
| 593 |
+
if is_initial:
|
| 594 |
+
initial_style = "color: #333333 !important; font-weight: 500 !important;"
|
| 595 |
+
initial_class = "mari-initial-message"
|
| 596 |
+
# 初期メッセージは背景色を固定
|
| 597 |
+
bg_color = "#F5F5F5"
|
| 598 |
+
logger.debug(f"初期メッセージをフリップ表示: '{current_content}'")
|
| 599 |
+
else:
|
| 600 |
+
initial_style = ""
|
| 601 |
+
initial_class = ""
|
| 602 |
+
# 通常メッセージは背景色を動的に設定
|
| 603 |
+
bg_color = "#FFF8E1" if is_flipped else "#F5F5F5"
|
| 604 |
+
|
| 605 |
+
# メッセージを全幅で表示(ボタンは削除)
|
| 606 |
+
message_style = f"""
|
| 607 |
+
<div style="
|
| 608 |
+
padding: 15px;
|
| 609 |
+
background: {bg_color};
|
| 610 |
+
border-radius: 12px;
|
| 611 |
+
border: 1px solid rgba(0,0,0,0.1);
|
| 612 |
+
min-height: 50px;
|
| 613 |
+
font-family: var(--mari-font);
|
| 614 |
+
line-height: 1.7;
|
| 615 |
+
margin: 8px 0;
|
| 616 |
+
">
|
| 617 |
+
<div class="{initial_class}" style="{initial_style}">{current_content}</div>
|
| 618 |
+
</div>
|
| 619 |
+
"""
|
| 620 |
+
st.markdown(message_style, unsafe_allow_html=True)
|
| 621 |
+
|
| 622 |
+
# 本音表示機能の状態表示(開発用)
|
| 623 |
+
if st.session_state.get("debug_mode", False):
|
| 624 |
+
st.caption(f"🐕 Dog Mode: ID={message_id}, Hidden={len(hidden_content)>0}, Showing={is_flipped}")
|
| 625 |
+
|
| 626 |
+
except Exception as e:
|
| 627 |
+
logger.error(f"フリップアニメーション表示エラー: {e}")
|
| 628 |
+
# フォールバック: 通常のメッセージ表示
|
| 629 |
+
st.markdown(visible_content)
|
| 630 |
+
|
| 631 |
+
def _is_tutorial_message(self, message_id: str) -> bool:
|
| 632 |
+
"""
|
| 633 |
+
チュートリアル用のメッセージかどうかを判定する
|
| 634 |
+
|
| 635 |
+
Args:
|
| 636 |
+
message_id: メッセージID
|
| 637 |
+
|
| 638 |
+
Returns:
|
| 639 |
+
チュートリアルメッセージかどうか
|
| 640 |
+
"""
|
| 641 |
+
# 初回のマスク付きメッセージの場合はチュートリアル扱い
|
| 642 |
+
tutorial_completed = st.session_state.get('mask_tutorial_completed', False)
|
| 643 |
+
return not tutorial_completed and message_id == "msg_0"
|
| 644 |
+
|
| 645 |
+
def validate_input(self, message: str) -> Tuple[bool, str]:
|
| 646 |
+
"""
|
| 647 |
+
入力メッセージの検証
|
| 648 |
+
|
| 649 |
+
Args:
|
| 650 |
+
message: 入力メッセージ
|
| 651 |
+
|
| 652 |
+
Returns:
|
| 653 |
+
(検証結果, エラーメッセージ)
|
| 654 |
+
"""
|
| 655 |
+
if not message or not message.strip():
|
| 656 |
+
return False, "メッセージが空です。"
|
| 657 |
+
|
| 658 |
+
if len(message) > self.max_input_length:
|
| 659 |
+
return False, f"メッセージが長すぎます。{self.max_input_length}文字以内で入力してください。"
|
| 660 |
+
|
| 661 |
+
# 不正な文字のチェック
|
| 662 |
+
if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in message):
|
| 663 |
+
return False, "不正な文字が含まれています。"
|
| 664 |
+
|
| 665 |
+
return True, ""
|
| 666 |
+
|
| 667 |
+
def sanitize_message(self, message: str) -> str:
|
| 668 |
+
"""
|
| 669 |
+
メッセージをサニタイズする
|
| 670 |
+
|
| 671 |
+
Args:
|
| 672 |
+
message: 入力メッセージ
|
| 673 |
+
|
| 674 |
+
Returns:
|
| 675 |
+
サニタイズされたメッセージ
|
| 676 |
+
"""
|
| 677 |
+
try:
|
| 678 |
+
# 基本的なサニタイズ
|
| 679 |
+
sanitized = message.strip()
|
| 680 |
+
|
| 681 |
+
# HTMLエスケープ(Streamlitが自動で行うが念のため)
|
| 682 |
+
sanitized = sanitized.replace("<", "<").replace(">", ">")
|
| 683 |
+
|
| 684 |
+
# 連続する空白を単一の空白に変換
|
| 685 |
+
import re
|
| 686 |
+
sanitized = re.sub(r'\s+', ' ', sanitized)
|
| 687 |
+
|
| 688 |
+
return sanitized
|
| 689 |
+
|
| 690 |
+
except Exception as e:
|
| 691 |
+
logger.error(f"メッセージサニタイズエラー: {e}")
|
| 692 |
+
return message
|
| 693 |
+
|
| 694 |
+
def add_message(self, role: str, content: str,
|
| 695 |
+
messages: Optional[List[Dict[str, str]]] = None,
|
| 696 |
+
message_id: Optional[str] = None) -> List[Dict[str, str]]:
|
| 697 |
+
"""
|
| 698 |
+
メッセージをリストに追加する(マスク機能対応)
|
| 699 |
+
|
| 700 |
+
Args:
|
| 701 |
+
role: メッセージの役割 ('user' or 'assistant')
|
| 702 |
+
content: メッセージ内容
|
| 703 |
+
messages: メッセージリスト(Noneの場合はsession_stateから取得)
|
| 704 |
+
message_id: メッセージの一意ID(Noneの場合は自動生成)
|
| 705 |
+
|
| 706 |
+
Returns:
|
| 707 |
+
更新されたメッセージリスト
|
| 708 |
+
"""
|
| 709 |
+
try:
|
| 710 |
+
if messages is None:
|
| 711 |
+
messages = st.session_state.get('messages', [])
|
| 712 |
+
|
| 713 |
+
# メッセージIDを生成または使用
|
| 714 |
+
if message_id is None:
|
| 715 |
+
message_id = f"msg_{len(messages)}_{uuid.uuid4().hex[:8]}"
|
| 716 |
+
|
| 717 |
+
# メッセージオブジェクトを作成
|
| 718 |
+
message = {
|
| 719 |
+
"role": role,
|
| 720 |
+
"content": self.sanitize_message(content),
|
| 721 |
+
"timestamp": datetime.now().isoformat(),
|
| 722 |
+
"message_id": message_id
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
messages.append(message)
|
| 726 |
+
|
| 727 |
+
# セッション状態を更新
|
| 728 |
+
st.session_state.messages = messages
|
| 729 |
+
|
| 730 |
+
logger.info(f"メッセージを追加: {role} - {len(content)}文字 (ID: {message_id})")
|
| 731 |
+
return messages
|
| 732 |
+
|
| 733 |
+
except Exception as e:
|
| 734 |
+
logger.error(f"メッセージ追加エラー: {e}")
|
| 735 |
+
return messages or []
|
| 736 |
+
|
| 737 |
+
def create_hidden_content_message(self, visible_content: str, hidden_content: str) -> str:
|
| 738 |
+
"""
|
| 739 |
+
隠された真実を含むメッセージを作成する
|
| 740 |
+
|
| 741 |
+
Args:
|
| 742 |
+
visible_content: 表示される内容
|
| 743 |
+
hidden_content: 隠された内容
|
| 744 |
+
|
| 745 |
+
Returns:
|
| 746 |
+
マーカー付きメッセージ
|
| 747 |
+
"""
|
| 748 |
+
return f"[HIDDEN:{hidden_content}]{visible_content}"
|
| 749 |
+
|
| 750 |
+
def generate_mock_hidden_content(self, visible_content: str) -> str:
|
| 751 |
+
"""
|
| 752 |
+
テスト用のモック隠された内容を生成する
|
| 753 |
+
|
| 754 |
+
Args:
|
| 755 |
+
visible_content: 表示される内容
|
| 756 |
+
|
| 757 |
+
Returns:
|
| 758 |
+
隠された内容
|
| 759 |
+
"""
|
| 760 |
+
# 簡単なモック生成ロジック
|
| 761 |
+
mock_patterns = {
|
| 762 |
+
"何の用?": "(本当は嬉しいけど...素直になれない)",
|
| 763 |
+
"別に": "(実はすごく気になってる)",
|
| 764 |
+
"そうね": "(もっと話していたい)",
|
| 765 |
+
"まあまあ": "(とても楽しい!)",
|
| 766 |
+
"普通": "(特別な時間だと思ってる)",
|
| 767 |
+
"いいんじゃない": "(すごく良いと思う!)",
|
| 768 |
+
"そんなことない": "(本当はそう思ってる)"
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
for pattern, hidden in mock_patterns.items():
|
| 772 |
+
if pattern in visible_content:
|
| 773 |
+
return hidden
|
| 774 |
+
|
| 775 |
+
# デフォルトの隠された内容
|
| 776 |
+
return "(本当の気持ちは...)"
|
| 777 |
+
|
| 778 |
+
def render_input_area(self, placeholder: str = "メッセージを入力してください...") -> Optional[str]:
|
| 779 |
+
"""
|
| 780 |
+
入力エリアをレンダリングし、入力を取得する
|
| 781 |
+
|
| 782 |
+
Args:
|
| 783 |
+
placeholder: 入力フィールドのプレースホルダー
|
| 784 |
+
|
| 785 |
+
Returns:
|
| 786 |
+
入力されたメッセージ(入力がない場合はNone)
|
| 787 |
+
"""
|
| 788 |
+
try:
|
| 789 |
+
# レート制限チェック
|
| 790 |
+
if st.session_state.get('limiter_state', {}).get('is_blocked', False):
|
| 791 |
+
st.warning("⏰ レート制限中です。しばらくお待ちください。")
|
| 792 |
+
st.chat_input(placeholder, disabled=True)
|
| 793 |
+
return None
|
| 794 |
+
|
| 795 |
+
# 通常の入力フィールド
|
| 796 |
+
user_input = st.chat_input(placeholder)
|
| 797 |
+
|
| 798 |
+
if user_input:
|
| 799 |
+
# 入力検証
|
| 800 |
+
is_valid, error_msg = self.validate_input(user_input)
|
| 801 |
+
if not is_valid:
|
| 802 |
+
st.error(error_msg)
|
| 803 |
+
return None
|
| 804 |
+
|
| 805 |
+
return user_input
|
| 806 |
+
|
| 807 |
+
return None
|
| 808 |
+
|
| 809 |
+
except Exception as e:
|
| 810 |
+
logger.error(f"入力エリア表示エラー: {e}")
|
| 811 |
+
st.error("入力エリアの表示中にエラーが発生しました。")
|
| 812 |
+
return None
|
| 813 |
+
|
| 814 |
+
def show_typing_indicator(self, message: str = "考え中...") -> None:
|
| 815 |
+
"""
|
| 816 |
+
タイピングインジケーターを表示する
|
| 817 |
+
|
| 818 |
+
Args:
|
| 819 |
+
message: 表示するメッセージ
|
| 820 |
+
"""
|
| 821 |
+
return st.spinner(message)
|
| 822 |
+
|
| 823 |
+
def clear_chat_history(self) -> None:
|
| 824 |
+
"""チャット履歴をクリアする"""
|
| 825 |
+
try:
|
| 826 |
+
st.session_state.messages = []
|
| 827 |
+
logger.info("チャット履歴をクリアしました")
|
| 828 |
+
|
| 829 |
+
except Exception as e:
|
| 830 |
+
logger.error(f"チャット履歴クリアエラー: {e}")
|
| 831 |
+
|
| 832 |
+
def get_chat_stats(self) -> Dict[str, int]:
|
| 833 |
+
"""
|
| 834 |
+
チャットの統計情報を取得する
|
| 835 |
+
|
| 836 |
+
Returns:
|
| 837 |
+
統計情報の辞書
|
| 838 |
+
"""
|
| 839 |
+
try:
|
| 840 |
+
messages = st.session_state.get('messages', [])
|
| 841 |
+
|
| 842 |
+
user_messages = [msg for msg in messages if msg.get("role") == "user"]
|
| 843 |
+
assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"]
|
| 844 |
+
|
| 845 |
+
total_chars = sum(len(msg.get("content", "")) for msg in messages)
|
| 846 |
+
|
| 847 |
+
return {
|
| 848 |
+
"total_messages": len(messages),
|
| 849 |
+
"user_messages": len(user_messages),
|
| 850 |
+
"assistant_messages": len(assistant_messages),
|
| 851 |
+
"total_characters": total_chars,
|
| 852 |
+
"average_message_length": total_chars // len(messages) if messages else 0
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
except Exception as e:
|
| 856 |
+
logger.error(f"統計情報取得エラー: {e}")
|
| 857 |
+
return {
|
| 858 |
+
"total_messages": 0,
|
| 859 |
+
"user_messages": 0,
|
| 860 |
+
"assistant_messages": 0,
|
| 861 |
+
"total_characters": 0,
|
| 862 |
+
"average_message_length": 0
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
def export_chat_history(self) -> str:
|
| 866 |
+
"""
|
| 867 |
+
チャット履歴をエクスポート用の文字列として取得する
|
| 868 |
+
|
| 869 |
+
Returns:
|
| 870 |
+
エクスポート用の文字列
|
| 871 |
+
"""
|
| 872 |
+
try:
|
| 873 |
+
messages = st.session_state.get('messages', [])
|
| 874 |
+
|
| 875 |
+
if not messages:
|
| 876 |
+
return "チャット履歴がありません。"
|
| 877 |
+
|
| 878 |
+
export_lines = []
|
| 879 |
+
export_lines.append("=== 麻理チャット履歴 ===")
|
| 880 |
+
export_lines.append(f"エクスポート日時: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 881 |
+
export_lines.append("")
|
| 882 |
+
|
| 883 |
+
for i, message in enumerate(messages, 1):
|
| 884 |
+
role = "ユーザー" if message.get("role") == "user" else "麻理"
|
| 885 |
+
content = message.get("content", "")
|
| 886 |
+
timestamp = message.get("timestamp", "")
|
| 887 |
+
|
| 888 |
+
export_lines.append(f"[{i}] {role}: {content}")
|
| 889 |
+
if timestamp:
|
| 890 |
+
export_lines.append(f" 時刻: {timestamp}")
|
| 891 |
+
export_lines.append("")
|
| 892 |
+
|
| 893 |
+
return "\n".join(export_lines)
|
| 894 |
+
|
| 895 |
+
except Exception as e:
|
| 896 |
+
logger.error(f"履歴エクスポートエラー: {e}")
|
| 897 |
+
return "エクスポート中にエラーが発生しました。"
|
components_dog_assistant.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ポチ(犬)アシスタントコンポーネント
|
| 3 |
+
画面右下に固定配置され、本音表示機能を提供する
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class DogAssistant:
|
| 11 |
+
"""ポチ(犬)アシスタントクラス"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
"""初期化"""
|
| 15 |
+
self.default_message = "ポチは麻理の本音を察知したようだ・・・"
|
| 16 |
+
self.active_message = "ワンワン!本音が見えてるワン!"
|
| 17 |
+
|
| 18 |
+
def render_dog_component(self, tutorial_manager=None):
|
| 19 |
+
"""画面右下に固定配置される犬のコンポーネントを描画"""
|
| 20 |
+
try:
|
| 21 |
+
# 犬のボタン表示前にチャットセッション状態を確認
|
| 22 |
+
if 'chat' not in st.session_state:
|
| 23 |
+
logger.warning("犬のコンポーネント表示前にチャットセッションが存在しません - 初期化します")
|
| 24 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 25 |
+
st.session_state.chat = {
|
| 26 |
+
"messages": [initial_message],
|
| 27 |
+
"affection": 30,
|
| 28 |
+
"scene_params": {"theme": "default"},
|
| 29 |
+
"limiter_state": {},
|
| 30 |
+
"scene_change_pending": None,
|
| 31 |
+
"ura_mode": False
|
| 32 |
+
}
|
| 33 |
+
logger.info("犬のコンポーネント表示前にチャットセッションを初期化しました")
|
| 34 |
+
elif 'messages' not in st.session_state.chat:
|
| 35 |
+
logger.warning("犬のコンポーネント表示前にメッセージリストが存在しません - 初期化します")
|
| 36 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 37 |
+
st.session_state.chat['messages'] = [initial_message]
|
| 38 |
+
logger.info("犬のコンポーネント表示前にメッセージリストを初期化しました")
|
| 39 |
+
elif not any(msg.get('is_initial', False) for msg in st.session_state.chat['messages']):
|
| 40 |
+
logger.warning("犬のコンポーネント表示前に初期メッセージが見つかりません - 復元します")
|
| 41 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 42 |
+
st.session_state.chat['messages'].insert(0, initial_message)
|
| 43 |
+
logger.info("犬のコンポーネント表示前に初期メッセージを復元しました")
|
| 44 |
+
# 犬のコンポーネントのCSS(レスポンシブ対応)
|
| 45 |
+
dog_css = """
|
| 46 |
+
<style>
|
| 47 |
+
.dog-assistant-container {
|
| 48 |
+
position: fixed;
|
| 49 |
+
bottom: 20px;
|
| 50 |
+
right: 20px;
|
| 51 |
+
z-index: 1000;
|
| 52 |
+
display: flex;
|
| 53 |
+
flex-direction: column;
|
| 54 |
+
align-items: flex-start;
|
| 55 |
+
pointer-events: none;
|
| 56 |
+
transform: translateX(-50px);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.dog-speech-bubble {
|
| 60 |
+
background-color: rgba(255, 255, 255, 0.95);
|
| 61 |
+
color: #333;
|
| 62 |
+
padding: 10px 15px;
|
| 63 |
+
border-radius: 20px;
|
| 64 |
+
font-size: 13px;
|
| 65 |
+
margin-bottom: 10px;
|
| 66 |
+
position: relative;
|
| 67 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 68 |
+
backdrop-filter: blur(10px);
|
| 69 |
+
border: 1px solid rgba(0,0,0,0.1);
|
| 70 |
+
max-width: 200px;
|
| 71 |
+
word-wrap: break-word;
|
| 72 |
+
animation: bubbleFloat 3s ease-in-out infinite;
|
| 73 |
+
pointer-events: auto;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.dog-speech-bubble::after {
|
| 77 |
+
content: '';
|
| 78 |
+
position: absolute;
|
| 79 |
+
bottom: -8px;
|
| 80 |
+
left: 20px;
|
| 81 |
+
width: 0;
|
| 82 |
+
height: 0;
|
| 83 |
+
border-left: 8px solid transparent;
|
| 84 |
+
border-right: 8px solid transparent;
|
| 85 |
+
border-top: 8px solid rgba(255, 255, 255, 0.95);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.dog-button {
|
| 89 |
+
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fecfef 100%);
|
| 90 |
+
border: none;
|
| 91 |
+
border-radius: 50%;
|
| 92 |
+
width: 70px;
|
| 93 |
+
height: 70px;
|
| 94 |
+
cursor: pointer;
|
| 95 |
+
transition: all 0.3s ease;
|
| 96 |
+
box-shadow: 0 4px 15px rgba(255, 154, 158, 0.4);
|
| 97 |
+
display: flex;
|
| 98 |
+
align-items: center;
|
| 99 |
+
justify-content: center;
|
| 100 |
+
font-size: 35px;
|
| 101 |
+
pointer-events: auto;
|
| 102 |
+
animation: dogBounce 2s ease-in-out infinite;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
.dog-button:hover {
|
| 106 |
+
transform: scale(1.1);
|
| 107 |
+
box-shadow: 0 6px 20px rgba(255, 154, 158, 0.6);
|
| 108 |
+
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 50%, #ff9ff3 100%);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.dog-button:active {
|
| 112 |
+
transform: scale(0.95);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.dog-button.active {
|
| 116 |
+
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
| 117 |
+
animation: dogActive 1s ease-in-out infinite;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
@keyframes bubbleFloat {
|
| 121 |
+
0%, 100% { transform: translateY(0px); }
|
| 122 |
+
50% { transform: translateY(-5px); }
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
@keyframes dogBounce {
|
| 126 |
+
0%, 100% { transform: translateY(0px); }
|
| 127 |
+
50% { transform: translateY(-3px); }
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
@keyframes dogActive {
|
| 131 |
+
0%, 100% { transform: scale(1) rotate(0deg); }
|
| 132 |
+
25% { transform: scale(1.05) rotate(-2deg); }
|
| 133 |
+
75% { transform: scale(1.05) rotate(2deg); }
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/* レスポンシブ対応 */
|
| 137 |
+
@media (max-width: 768px) {
|
| 138 |
+
.dog-assistant-container {
|
| 139 |
+
bottom: 15px;
|
| 140 |
+
right: 15px;
|
| 141 |
+
transform: translateX(-40px);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
.dog-speech-bubble {
|
| 145 |
+
max-width: 150px;
|
| 146 |
+
font-size: 12px;
|
| 147 |
+
padding: 8px 12px;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.dog-button {
|
| 151 |
+
width: 60px;
|
| 152 |
+
height: 60px;
|
| 153 |
+
font-size: 30px;
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
@media (max-width: 480px) {
|
| 158 |
+
.dog-assistant-container {
|
| 159 |
+
bottom: 10px;
|
| 160 |
+
right: 10px;
|
| 161 |
+
transform: translateX(-30px);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.dog-speech-bubble {
|
| 165 |
+
max-width: 120px;
|
| 166 |
+
font-size: 11px;
|
| 167 |
+
padding: 6px 10px;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.dog-button {
|
| 171 |
+
width: 50px;
|
| 172 |
+
height: 50px;
|
| 173 |
+
font-size: 25px;
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/* 画面が非常に小さい場合は吹き出しを非表示 */
|
| 178 |
+
@media (max-width: 320px) {
|
| 179 |
+
.dog-speech-bubble {
|
| 180 |
+
display: none;
|
| 181 |
+
}
|
| 182 |
+
}
|
| 183 |
+
</style>
|
| 184 |
+
"""
|
| 185 |
+
|
| 186 |
+
# 現在の状態を取得
|
| 187 |
+
is_active = st.session_state.get('show_all_hidden', False)
|
| 188 |
+
bubble_text = self.active_message if is_active else self.default_message
|
| 189 |
+
button_class = "dog-button active" if is_active else "dog-button"
|
| 190 |
+
|
| 191 |
+
# JavaScriptでクリックイベントを処理
|
| 192 |
+
dog_js = f"""
|
| 193 |
+
<script>
|
| 194 |
+
function toggleDogMode() {{
|
| 195 |
+
// Streamlitのセッション状態を更新するためのトリガー
|
| 196 |
+
const event = new CustomEvent('dogButtonClick', {{
|
| 197 |
+
detail: {{ active: {str(is_active).lower()} }}
|
| 198 |
+
}});
|
| 199 |
+
window.dispatchEvent(event);
|
| 200 |
+
|
| 201 |
+
// ボタンの状態を即座に更新
|
| 202 |
+
const button = document.querySelector('.dog-button');
|
| 203 |
+
const bubble = document.querySelector('.dog-speech-bubble');
|
| 204 |
+
|
| 205 |
+
if (button && bubble) {{
|
| 206 |
+
if ({str(is_active).lower()}) {{
|
| 207 |
+
button.classList.remove('active');
|
| 208 |
+
bubble.textContent = '{self.default_message}';
|
| 209 |
+
}} else {{
|
| 210 |
+
button.classList.add('active');
|
| 211 |
+
bubble.textContent = '{self.active_message}';
|
| 212 |
+
}}
|
| 213 |
+
}}
|
| 214 |
+
}}
|
| 215 |
+
|
| 216 |
+
// ページ読み込み時にイベントリスナーを設定
|
| 217 |
+
document.addEventListener('DOMContentLoaded', function() {{
|
| 218 |
+
const button = document.querySelector('.dog-button');
|
| 219 |
+
if (button) {{
|
| 220 |
+
button.addEventListener('click', toggleDogMode);
|
| 221 |
+
}}
|
| 222 |
+
}});
|
| 223 |
+
|
| 224 |
+
// Streamlitの再描画後にもイベントリスナーを再設定
|
| 225 |
+
setTimeout(function() {{
|
| 226 |
+
const button = document.querySelector('.dog-button');
|
| 227 |
+
if (button) {{
|
| 228 |
+
button.addEventListener('click', toggleDogMode);
|
| 229 |
+
}}
|
| 230 |
+
}}, 100);
|
| 231 |
+
</script>
|
| 232 |
+
"""
|
| 233 |
+
|
| 234 |
+
# HTMLコンポーネント
|
| 235 |
+
dog_html = f"""
|
| 236 |
+
<div class="dog-assistant-container">
|
| 237 |
+
<div class="dog-speech-bubble">
|
| 238 |
+
{bubble_text}
|
| 239 |
+
</div>
|
| 240 |
+
<button class="{button_class}" title="ポチが麻理の本音を察知します" onclick="toggleDogMode()">
|
| 241 |
+
🐕
|
| 242 |
+
</button>
|
| 243 |
+
</div>
|
| 244 |
+
"""
|
| 245 |
+
|
| 246 |
+
# HTMLコンポーネント(ボタン以外)を表示
|
| 247 |
+
dog_display_html = f"""
|
| 248 |
+
<div class="dog-assistant-container">
|
| 249 |
+
<div class="dog-speech-bubble">
|
| 250 |
+
{bubble_text}
|
| 251 |
+
</div>
|
| 252 |
+
<div style="width: 70px; height: 70px; display: flex; align-items: center; justify-content: center;">
|
| 253 |
+
<!-- Streamlitボタンがここに配置される -->
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
"""
|
| 257 |
+
|
| 258 |
+
st.markdown(dog_css + dog_display_html, unsafe_allow_html=True)
|
| 259 |
+
|
| 260 |
+
# Streamlitボタンを固定位置に配置
|
| 261 |
+
button_css = """
|
| 262 |
+
<style>
|
| 263 |
+
.dog-button-overlay {
|
| 264 |
+
position: fixed;
|
| 265 |
+
bottom: 20px;
|
| 266 |
+
right: 20px;
|
| 267 |
+
z-index: 1001;
|
| 268 |
+
pointer-events: auto;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.dog-button-overlay .stButton > button {
|
| 272 |
+
background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
|
| 273 |
+
border: none;
|
| 274 |
+
border-radius: 50%;
|
| 275 |
+
width: 70px;
|
| 276 |
+
height: 70px;
|
| 277 |
+
font-size: 35px;
|
| 278 |
+
color: white;
|
| 279 |
+
box-shadow: 0 4px 15px rgba(255, 154, 158, 0.4);
|
| 280 |
+
transition: all 0.3s ease;
|
| 281 |
+
animation: dogBounce 2s ease-in-out infinite;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.dog-button-overlay .stButton > button:hover {
|
| 285 |
+
transform: scale(1.1);
|
| 286 |
+
box-shadow: 0 6px 20px rgba(255, 154, 158, 0.6);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
@keyframes dogBounce {
|
| 290 |
+
0%, 100% { transform: translateY(0px); }
|
| 291 |
+
50% { transform: translateY(-3px); }
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
@media (max-width: 768px) {
|
| 295 |
+
.dog-button-overlay {
|
| 296 |
+
bottom: 15px;
|
| 297 |
+
right: 15px;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
.dog-button-overlay .stButton > button {
|
| 301 |
+
width: 60px;
|
| 302 |
+
height: 60px;
|
| 303 |
+
font-size: 30px;
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
@media (max-width: 480px) {
|
| 308 |
+
.dog-button-overlay {
|
| 309 |
+
bottom: 10px;
|
| 310 |
+
right: 10px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.dog-button-overlay .stButton > button {
|
| 314 |
+
width: 50px;
|
| 315 |
+
height: 50px;
|
| 316 |
+
font-size: 25px;
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
</style>
|
| 320 |
+
"""
|
| 321 |
+
|
| 322 |
+
st.markdown(button_css, unsafe_allow_html=True)
|
| 323 |
+
st.markdown('<div class="dog-button-overlay">', unsafe_allow_html=True)
|
| 324 |
+
|
| 325 |
+
# ボタンクリック処理
|
| 326 |
+
button_key = f"dog_fixed_{is_active}"
|
| 327 |
+
button_help = "本音を隠す" if is_active else "本音を見る"
|
| 328 |
+
if st.button("🐕", key=button_key, help=button_help):
|
| 329 |
+
self.handle_dog_button_click(tutorial_manager)
|
| 330 |
+
logger.info("右下の犬のボタンがクリックされました")
|
| 331 |
+
|
| 332 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 333 |
+
|
| 334 |
+
logger.debug(f"犬のコンポーネントを描画しました (active: {is_active})")
|
| 335 |
+
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"犬のコンポーネント描画エラー: {e}")
|
| 338 |
+
|
| 339 |
+
def handle_dog_button_click(self, tutorial_manager=None):
|
| 340 |
+
"""犬のボタンクリック処理(無限ループ防止版)"""
|
| 341 |
+
try:
|
| 342 |
+
# 本音表示機能のトリガー
|
| 343 |
+
if 'show_all_hidden' not in st.session_state:
|
| 344 |
+
st.session_state.show_all_hidden = False
|
| 345 |
+
|
| 346 |
+
# 現在の状態を取得
|
| 347 |
+
current_state = st.session_state.show_all_hidden
|
| 348 |
+
new_state = not current_state
|
| 349 |
+
|
| 350 |
+
# 状態を更新
|
| 351 |
+
st.session_state.show_all_hidden = new_state
|
| 352 |
+
# チャット履歴の強制再表示フラグを設定
|
| 353 |
+
st.session_state.show_all_hidden_changed = True
|
| 354 |
+
logger.info(f"犬のボタン状態変更: {current_state} -> {new_state}")
|
| 355 |
+
|
| 356 |
+
# 全メッセージのフリップ状態を即座に更新
|
| 357 |
+
if 'message_flip_states' not in st.session_state:
|
| 358 |
+
st.session_state.message_flip_states = {}
|
| 359 |
+
|
| 360 |
+
# 現在のメッセージに対してフリップ状態を設定
|
| 361 |
+
if 'chat' in st.session_state and 'messages' in st.session_state.chat:
|
| 362 |
+
# 初期メッセージが存在することを確認
|
| 363 |
+
messages = st.session_state.chat['messages']
|
| 364 |
+
if not any(msg.get('is_initial', False) for msg in messages):
|
| 365 |
+
logger.warning("犬のボタン押下時に初期メッセージが見つかりません - 復元します")
|
| 366 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 367 |
+
st.session_state.chat['messages'].insert(0, initial_message)
|
| 368 |
+
logger.info("犬のボタン押下時に初期メッセージを復元しました")
|
| 369 |
+
|
| 370 |
+
for i, message in enumerate(st.session_state.chat['messages']):
|
| 371 |
+
if message['role'] == 'assistant':
|
| 372 |
+
message_id = f"msg_{i}"
|
| 373 |
+
st.session_state.message_flip_states[message_id] = new_state
|
| 374 |
+
else:
|
| 375 |
+
logger.warning("犬のボタン押下時にチャットセッションが存在しません - 初期化します")
|
| 376 |
+
# チャットセッションが存在しない場合は初期化
|
| 377 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 378 |
+
if 'chat' not in st.session_state:
|
| 379 |
+
st.session_state.chat = {
|
| 380 |
+
"messages": [initial_message],
|
| 381 |
+
"affection": 30,
|
| 382 |
+
"scene_params": {"theme": "default"},
|
| 383 |
+
"limiter_state": {},
|
| 384 |
+
"scene_change_pending": None,
|
| 385 |
+
"ura_mode": False
|
| 386 |
+
}
|
| 387 |
+
else:
|
| 388 |
+
st.session_state.chat['messages'] = [initial_message]
|
| 389 |
+
logger.info("犬のボタン押下時にチャットセッションを初期化しました")
|
| 390 |
+
|
| 391 |
+
# チュートリアルステップ2を完了(tutorial_managerが渡された場合)
|
| 392 |
+
if tutorial_manager:
|
| 393 |
+
tutorial_manager.check_step_completion(2, True)
|
| 394 |
+
|
| 395 |
+
# 通知メッセージ(一度だけ表示)
|
| 396 |
+
if new_state:
|
| 397 |
+
st.success("🐕 ポチが麻理の本音を察知しました!")
|
| 398 |
+
else:
|
| 399 |
+
st.info("🐕 ポチが通常モードに戻りました。")
|
| 400 |
+
|
| 401 |
+
logger.info(f"犬のボタン状態変更完了: {current_state} → {new_state}")
|
| 402 |
+
|
| 403 |
+
# 状態変更を確実に反映するため、強制的に再実行
|
| 404 |
+
st.rerun()
|
| 405 |
+
|
| 406 |
+
except Exception as e:
|
| 407 |
+
logger.error(f"犬のボタンクリック処理エラー: {e}")
|
| 408 |
+
|
| 409 |
+
def render_with_streamlit_button(self):
|
| 410 |
+
"""Streamlitのボタンを使用した代替実装(フォールバック用)"""
|
| 411 |
+
try:
|
| 412 |
+
# 固定位置のCSS
|
| 413 |
+
fallback_css = """
|
| 414 |
+
<style>
|
| 415 |
+
.dog-fallback-container {
|
| 416 |
+
position: fixed;
|
| 417 |
+
bottom: 20px;
|
| 418 |
+
right: 20px;
|
| 419 |
+
z-index: 1000;
|
| 420 |
+
background: rgba(255, 255, 255, 0.9);
|
| 421 |
+
border-radius: 15px;
|
| 422 |
+
padding: 10px;
|
| 423 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
| 424 |
+
backdrop-filter: blur(10px);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
@media (max-width: 768px) {
|
| 428 |
+
.dog-fallback-container {
|
| 429 |
+
bottom: 15px;
|
| 430 |
+
right: 15px;
|
| 431 |
+
padding: 8px;
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
</style>
|
| 435 |
+
"""
|
| 436 |
+
|
| 437 |
+
st.markdown(fallback_css, unsafe_allow_html=True)
|
| 438 |
+
|
| 439 |
+
# コンテナの開始
|
| 440 |
+
st.markdown('<div class="dog-fallback-container">', unsafe_allow_html=True)
|
| 441 |
+
|
| 442 |
+
# 状態表示
|
| 443 |
+
is_active = st.session_state.get('show_all_hidden', False)
|
| 444 |
+
status_text = "本音モード中" if is_active else "通常モード"
|
| 445 |
+
st.caption(f"🐕 {status_text}")
|
| 446 |
+
|
| 447 |
+
# ボタン
|
| 448 |
+
button_text = "🔄 戻す" if is_active else "🐕 本音を見る"
|
| 449 |
+
if st.button(button_text, key="dog_assistant_btn"):
|
| 450 |
+
# チュートリアルマネージャーを取得(可能な場合)
|
| 451 |
+
tutorial_manager = None
|
| 452 |
+
try:
|
| 453 |
+
# セッション状態からチュートリアルマネージャーを取得する試み
|
| 454 |
+
# (完全ではないが、フォールバック用)
|
| 455 |
+
pass
|
| 456 |
+
except:
|
| 457 |
+
pass
|
| 458 |
+
|
| 459 |
+
self.handle_dog_button_click(tutorial_manager)
|
| 460 |
+
# st.rerun()を削除 - 状態変更により自動的に再描画される
|
| 461 |
+
|
| 462 |
+
# コンテナの終了
|
| 463 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 464 |
+
|
| 465 |
+
except Exception as e:
|
| 466 |
+
logger.error(f"犬のフォールバック描画エラー: {e}")
|
| 467 |
+
|
| 468 |
+
def get_current_state(self):
|
| 469 |
+
"""現在の犬の状態を取得"""
|
| 470 |
+
return {
|
| 471 |
+
'is_active': st.session_state.get('show_all_hidden', False),
|
| 472 |
+
'message': self.active_message if st.session_state.get('show_all_hidden', False) else self.default_message
|
| 473 |
+
}
|
components_status_display.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ステータス表示コンポーネント
|
| 3 |
+
好感度ゲージと関係ステージの表示を担当する
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, Tuple
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class StatusDisplay:
|
| 12 |
+
"""ステータス表示を管理するクラス"""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
"""ステータス表示の初期化"""
|
| 16 |
+
self.stage_colors = {
|
| 17 |
+
"敵対": {"color": "#ff4757", "emoji": "🔴", "bg_color": "rgba(255, 71, 87, 0.1)"},
|
| 18 |
+
"警戒": {"color": "#ff6348", "emoji": "🟠", "bg_color": "rgba(255, 99, 72, 0.1)"},
|
| 19 |
+
"中立": {"color": "#ffa502", "emoji": "🟡", "bg_color": "rgba(255, 165, 2, 0.1)"},
|
| 20 |
+
"好意": {"color": "#2ed573", "emoji": "🟢", "bg_color": "rgba(46, 213, 115, 0.1)"},
|
| 21 |
+
"親密": {"color": "#a55eea", "emoji": "💜", "bg_color": "rgba(165, 94, 234, 0.1)"}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
def get_affection_color(self, affection: int) -> str:
|
| 25 |
+
"""
|
| 26 |
+
好感度に基づいて色を取得する
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
affection: 好感度値 (0-100)
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
色のHEXコード
|
| 33 |
+
"""
|
| 34 |
+
if affection < 20:
|
| 35 |
+
return "#ff4757" # 赤
|
| 36 |
+
elif affection < 40:
|
| 37 |
+
return "#ff6348" # オレンジ
|
| 38 |
+
elif affection < 60:
|
| 39 |
+
return "#ffa502" # 黄色
|
| 40 |
+
elif affection < 80:
|
| 41 |
+
return "#2ed573" # 緑
|
| 42 |
+
else:
|
| 43 |
+
return "#a55eea" # 紫
|
| 44 |
+
|
| 45 |
+
def get_relationship_stage_info(self, affection: int) -> Dict[str, str]:
|
| 46 |
+
"""
|
| 47 |
+
好感度から関係性ステージの情報を取得する
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
affection: 好感度値 (0-100)
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
ステージ情報の辞書
|
| 54 |
+
"""
|
| 55 |
+
if affection < 20:
|
| 56 |
+
stage = "敵対"
|
| 57 |
+
elif affection < 40:
|
| 58 |
+
stage = "警戒"
|
| 59 |
+
elif affection < 60:
|
| 60 |
+
stage = "中立"
|
| 61 |
+
elif affection < 80:
|
| 62 |
+
stage = "好意"
|
| 63 |
+
else:
|
| 64 |
+
stage = "親密"
|
| 65 |
+
|
| 66 |
+
# 古いキー形式との互換性を保つため、新しいキーで検索し、見つからない場合は中立を返す
|
| 67 |
+
stage_info = None
|
| 68 |
+
for key, value in self.stage_colors.items():
|
| 69 |
+
if stage in key:
|
| 70 |
+
stage_info = value
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
return stage_info or self.stage_colors.get("中立", {"color": "#ffa502", "emoji": "🟡", "bg_color": "rgba(255, 165, 2, 0.1)"})
|
| 74 |
+
|
| 75 |
+
def render_affection_gauge(self, affection: int) -> None:
|
| 76 |
+
"""
|
| 77 |
+
好感度ゲージを表示する
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
affection: 好感度値 (0-100)
|
| 81 |
+
"""
|
| 82 |
+
try:
|
| 83 |
+
# 好感度の値を0-100の範囲に制限
|
| 84 |
+
affection = max(0, min(100, affection))
|
| 85 |
+
|
| 86 |
+
# 好感度メトリック表示
|
| 87 |
+
col1, col2 = st.columns([2, 1])
|
| 88 |
+
with col1:
|
| 89 |
+
st.metric("好感度", f"{affection}/100")
|
| 90 |
+
with col2:
|
| 91 |
+
# 好感度の変化を表示(前回の値と比較)
|
| 92 |
+
prev_affection = st.session_state.get('prev_affection', affection)
|
| 93 |
+
delta = affection - prev_affection
|
| 94 |
+
if delta != 0:
|
| 95 |
+
st.metric("変化", f"{delta:+d}")
|
| 96 |
+
st.session_state.prev_affection = affection
|
| 97 |
+
|
| 98 |
+
# プログレスバー
|
| 99 |
+
progress_value = affection / 100.0
|
| 100 |
+
affection_color = self.get_affection_color(affection)
|
| 101 |
+
|
| 102 |
+
# カスタムプログレスバーのCSS
|
| 103 |
+
progress_css = f"""
|
| 104 |
+
<style>
|
| 105 |
+
.affection-progress {{
|
| 106 |
+
width: 100%;
|
| 107 |
+
height: 25px;
|
| 108 |
+
background-color: rgba(255, 255, 255, 0.1);
|
| 109 |
+
border-radius: 12px;
|
| 110 |
+
overflow: hidden;
|
| 111 |
+
margin: 10px 0;
|
| 112 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 113 |
+
}}
|
| 114 |
+
.affection-fill {{
|
| 115 |
+
height: 100%;
|
| 116 |
+
background: linear-gradient(90deg,
|
| 117 |
+
#ff4757 0%,
|
| 118 |
+
#ff6348 25%,
|
| 119 |
+
#ffa502 50%,
|
| 120 |
+
#2ed573 75%,
|
| 121 |
+
#a55eea 100%);
|
| 122 |
+
width: {progress_value * 100}%;
|
| 123 |
+
transition: width 0.5s ease-in-out;
|
| 124 |
+
position: relative;
|
| 125 |
+
}}
|
| 126 |
+
.affection-text {{
|
| 127 |
+
position: absolute;
|
| 128 |
+
top: 50%;
|
| 129 |
+
left: 50%;
|
| 130 |
+
transform: translate(-50%, -50%);
|
| 131 |
+
color: white;
|
| 132 |
+
font-weight: bold;
|
| 133 |
+
text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
|
| 134 |
+
font-size: 12px;
|
| 135 |
+
}}
|
| 136 |
+
</style>
|
| 137 |
+
"""
|
| 138 |
+
|
| 139 |
+
st.markdown(progress_css, unsafe_allow_html=True)
|
| 140 |
+
|
| 141 |
+
# プログレ��バーのHTML
|
| 142 |
+
progress_html = f"""
|
| 143 |
+
<div class="affection-progress">
|
| 144 |
+
<div class="affection-fill">
|
| 145 |
+
<div class="affection-text">{affection}%</div>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
"""
|
| 149 |
+
|
| 150 |
+
st.markdown(progress_html, unsafe_allow_html=True)
|
| 151 |
+
|
| 152 |
+
# Streamlitの標準プログレスバーも表示(フォールバック)
|
| 153 |
+
st.progress(progress_value)
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"好感度ゲージ表示エラー: {e}")
|
| 157 |
+
# フォールバック表示
|
| 158 |
+
st.metric("好感度", f"{affection}/100")
|
| 159 |
+
st.progress(affection / 100.0)
|
| 160 |
+
|
| 161 |
+
def render_relationship_stage(self, affection: int) -> None:
|
| 162 |
+
"""
|
| 163 |
+
関係性ステージを表示する
|
| 164 |
+
|
| 165 |
+
Args:
|
| 166 |
+
affection: 好感度値 (0-100)
|
| 167 |
+
"""
|
| 168 |
+
try:
|
| 169 |
+
stage_info = self.get_relationship_stage_info(affection)
|
| 170 |
+
|
| 171 |
+
# ステージ名を取得
|
| 172 |
+
if affection < 20:
|
| 173 |
+
stage_name = "ステージ1:敵対"
|
| 174 |
+
stage_description = "麻理はあなたを敵視している"
|
| 175 |
+
elif affection < 40:
|
| 176 |
+
stage_name = "ステージ2:警戒"
|
| 177 |
+
stage_description = "麻理はあなたを警戒している"
|
| 178 |
+
elif affection < 60:
|
| 179 |
+
stage_name = "ステージ3:中立"
|
| 180 |
+
stage_description = "麻理はあなたに対して中立的"
|
| 181 |
+
elif affection < 80:
|
| 182 |
+
stage_name = "ステージ4:好意"
|
| 183 |
+
stage_description = "麻理はあなたに好意を持っている"
|
| 184 |
+
else:
|
| 185 |
+
stage_name = "ステージ5:親密"
|
| 186 |
+
stage_description = "麻理はあなたと親密な関係"
|
| 187 |
+
|
| 188 |
+
# ステージ表示のCSS
|
| 189 |
+
stage_css = f"""
|
| 190 |
+
<style>
|
| 191 |
+
.relationship-stage {{
|
| 192 |
+
background: {stage_info['bg_color']};
|
| 193 |
+
border: 2px solid {stage_info['color']};
|
| 194 |
+
border-radius: 10px;
|
| 195 |
+
padding: 15px;
|
| 196 |
+
margin: 10px 0;
|
| 197 |
+
text-align: center;
|
| 198 |
+
}}
|
| 199 |
+
.stage-emoji {{
|
| 200 |
+
font-size: 24px;
|
| 201 |
+
margin-bottom: 5px;
|
| 202 |
+
}}
|
| 203 |
+
.stage-name {{
|
| 204 |
+
color: {stage_info['color']};
|
| 205 |
+
font-weight: bold;
|
| 206 |
+
font-size: 16px;
|
| 207 |
+
margin-bottom: 5px;
|
| 208 |
+
}}
|
| 209 |
+
.stage-description {{
|
| 210 |
+
color: {stage_info['color']};
|
| 211 |
+
font-size: 12px;
|
| 212 |
+
opacity: 0.8;
|
| 213 |
+
}}
|
| 214 |
+
</style>
|
| 215 |
+
"""
|
| 216 |
+
|
| 217 |
+
st.markdown(stage_css, unsafe_allow_html=True)
|
| 218 |
+
|
| 219 |
+
# ステージ表示のHTML
|
| 220 |
+
stage_html = f"""
|
| 221 |
+
<div class="relationship-stage">
|
| 222 |
+
<div class="stage-emoji">{stage_info['emoji']}</div>
|
| 223 |
+
<div class="stage-name">{stage_name}</div>
|
| 224 |
+
<div class="stage-description">{stage_description}</div>
|
| 225 |
+
</div>
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
st.markdown(stage_html, unsafe_allow_html=True)
|
| 229 |
+
|
| 230 |
+
# フォールバック表示
|
| 231 |
+
st.write(f"{stage_info['emoji']} **関係性**: {stage_name}")
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(f"関係性ステージ表示エラー: {e}")
|
| 235 |
+
# フォールバック表示
|
| 236 |
+
if affection < 20:
|
| 237 |
+
st.write("🔴 **関係性**: ステージ1:敵対")
|
| 238 |
+
elif affection < 40:
|
| 239 |
+
st.write("🟠 **関係性**: ステージ2:中立")
|
| 240 |
+
elif affection < 60:
|
| 241 |
+
st.write("🟡 **関係性**: ステージ3:好意")
|
| 242 |
+
elif affection < 80:
|
| 243 |
+
st.write("🟢 **関係性**: ステージ4:親密")
|
| 244 |
+
else:
|
| 245 |
+
st.write("💜 **関係性**: ステージ5:最接近")
|
| 246 |
+
|
| 247 |
+
def render_affection_history(self, max_history: int = 10) -> None:
|
| 248 |
+
"""
|
| 249 |
+
好感度の履歴を表示する(デバッグモード用)
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
max_history: 表示する履歴の最大数
|
| 253 |
+
"""
|
| 254 |
+
try:
|
| 255 |
+
if not st.session_state.get('debug_mode', False):
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
# 好感度履歴を取得
|
| 259 |
+
affection_history = st.session_state.get('affection_history', [])
|
| 260 |
+
|
| 261 |
+
if not affection_history:
|
| 262 |
+
st.write("好感度の履歴がありません")
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
# 最新の履歴を表示
|
| 266 |
+
recent_history = affection_history[-max_history:]
|
| 267 |
+
|
| 268 |
+
st.subheader("📈 好感度履歴")
|
| 269 |
+
|
| 270 |
+
for i, entry in enumerate(reversed(recent_history)):
|
| 271 |
+
timestamp = entry.get('timestamp', 'Unknown')
|
| 272 |
+
affection = entry.get('affection', 0)
|
| 273 |
+
change = entry.get('change', 0)
|
| 274 |
+
message = entry.get('message', '')
|
| 275 |
+
|
| 276 |
+
change_str = f"({change:+d})" if change != 0 else ""
|
| 277 |
+
st.write(f"{i+1}. {affection}/100 {change_str} - {timestamp[:19]}")
|
| 278 |
+
if message:
|
| 279 |
+
st.caption(f"メッセージ: {message[:50]}...")
|
| 280 |
+
|
| 281 |
+
except Exception as e:
|
| 282 |
+
logger.error(f"好感度履歴表示エラー: {e}")
|
| 283 |
+
|
| 284 |
+
def update_affection_history(self, old_affection: int, new_affection: int,
|
| 285 |
+
message: str = "") -> None:
|
| 286 |
+
"""
|
| 287 |
+
好感度履歴を更新する
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
old_affection: 変更前の好感度
|
| 291 |
+
new_affection: 変更後の好感度
|
| 292 |
+
message: 関連するメッセージ
|
| 293 |
+
"""
|
| 294 |
+
try:
|
| 295 |
+
if 'affection_history' not in st.session_state:
|
| 296 |
+
st.session_state.affection_history = []
|
| 297 |
+
|
| 298 |
+
# 履歴エントリを作成
|
| 299 |
+
history_entry = {
|
| 300 |
+
'timestamp': st.session_state.get('current_timestamp', ''),
|
| 301 |
+
'affection': new_affection,
|
| 302 |
+
'change': new_affection - old_affection,
|
| 303 |
+
'message': message[:100] if message else '' # メッセージを100文字に制限
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
st.session_state.affection_history.append(history_entry)
|
| 307 |
+
|
| 308 |
+
# 履歴の長さを制限(最大50エントリ)
|
| 309 |
+
if len(st.session_state.affection_history) > 50:
|
| 310 |
+
st.session_state.affection_history = st.session_state.affection_history[-50:]
|
| 311 |
+
|
| 312 |
+
except Exception as e:
|
| 313 |
+
logger.error(f"好感度履歴更新エラー: {e}")
|
| 314 |
+
|
| 315 |
+
def get_affection_statistics(self) -> Dict[str, float]:
|
| 316 |
+
"""
|
| 317 |
+
好感度の統計情報を取得する
|
| 318 |
+
|
| 319 |
+
Returns:
|
| 320 |
+
統計情報の辞書
|
| 321 |
+
"""
|
| 322 |
+
try:
|
| 323 |
+
affection_history = st.session_state.get('affection_history', [])
|
| 324 |
+
|
| 325 |
+
if not affection_history:
|
| 326 |
+
return {
|
| 327 |
+
'current': st.session_state.get('affection', 30),
|
| 328 |
+
'average': 30.0,
|
| 329 |
+
'max': 30,
|
| 330 |
+
'min': 30,
|
| 331 |
+
'total_changes': 0
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
affections = [entry['affection'] for entry in affection_history]
|
| 335 |
+
changes = [entry['change'] for entry in affection_history if entry['change'] != 0]
|
| 336 |
+
|
| 337 |
+
return {
|
| 338 |
+
'current': st.session_state.get('affection', 30),
|
| 339 |
+
'average': sum(affections) / len(affections),
|
| 340 |
+
'max': max(affections),
|
| 341 |
+
'min': min(affections),
|
| 342 |
+
'total_changes': len(changes),
|
| 343 |
+
'positive_changes': len([c for c in changes if c > 0]),
|
| 344 |
+
'negative_changes': len([c for c in changes if c < 0])
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
except Exception as e:
|
| 348 |
+
logger.error(f"好感度統計取得エラー: {e}")
|
| 349 |
+
return {
|
| 350 |
+
'current': st.session_state.get('affection', 30),
|
| 351 |
+
'average': 30.0,
|
| 352 |
+
'max': 30,
|
| 353 |
+
'min': 30,
|
| 354 |
+
'total_changes': 0
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
def apply_status_styles(self) -> None:
|
| 358 |
+
"""
|
| 359 |
+
ステータス表示用のカスタムスタイルを適用する
|
| 360 |
+
"""
|
| 361 |
+
try:
|
| 362 |
+
status_css = """
|
| 363 |
+
<style>
|
| 364 |
+
/* ステータス表示全体のスタイル */
|
| 365 |
+
.status-container {
|
| 366 |
+
background: rgba(255, 255, 255, 0.1);
|
| 367 |
+
backdrop-filter: blur(15px);
|
| 368 |
+
border-radius: 15px;
|
| 369 |
+
padding: 20px;
|
| 370 |
+
margin: 15px 0;
|
| 371 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 372 |
+
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
| 373 |
+
transition: all 0.3s ease;
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
.status-container:hover {
|
| 377 |
+
background: rgba(255, 255, 255, 0.15);
|
| 378 |
+
transform: translateY(-3px);
|
| 379 |
+
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.15);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
/* メトリクス表示の改善 */
|
| 383 |
+
.stMetric {
|
| 384 |
+
background: rgba(255, 255, 255, 0.05);
|
| 385 |
+
padding: 10px;
|
| 386 |
+
border-radius: 8px;
|
| 387 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.stMetric > div {
|
| 391 |
+
color: white !important;
|
| 392 |
+
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/* 好感度ゲージのアニメーション */
|
| 396 |
+
.affection-progress {
|
| 397 |
+
position: relative;
|
| 398 |
+
overflow: hidden;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.affection-progress::before {
|
| 402 |
+
content: '';
|
| 403 |
+
position: absolute;
|
| 404 |
+
top: 0;
|
| 405 |
+
left: -100%;
|
| 406 |
+
width: 100%;
|
| 407 |
+
height: 100%;
|
| 408 |
+
background: linear-gradient(90deg,
|
| 409 |
+
transparent,
|
| 410 |
+
rgba(255, 255, 255, 0.3),
|
| 411 |
+
transparent);
|
| 412 |
+
animation: shimmer 2s infinite;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
@keyframes shimmer {
|
| 416 |
+
0% { left: -100%; }
|
| 417 |
+
100% { left: 100%; }
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
/* 関係性ステージのアニメーション */
|
| 421 |
+
.relationship-stage {
|
| 422 |
+
animation: stageGlow 3s ease-in-out infinite alternate;
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
@keyframes stageGlow {
|
| 426 |
+
0% { box-shadow: 0 0 5px rgba(255, 255, 255, 0.2); }
|
| 427 |
+
100% { box-shadow: 0 0 20px rgba(255, 255, 255, 0.4); }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/* ステージ変更時のエフェクト */
|
| 431 |
+
.stage-change-effect {
|
| 432 |
+
animation: stageChange 1s ease-in-out;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
@keyframes stageChange {
|
| 436 |
+
0% { transform: scale(1); opacity: 1; }
|
| 437 |
+
50% { transform: scale(1.05); opacity: 0.8; }
|
| 438 |
+
100% { transform: scale(1); opacity: 1; }
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
/* 好感度変化のエフェクト */
|
| 442 |
+
.affection-change-positive {
|
| 443 |
+
animation: positiveChange 0.8s ease-out;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.affection-change-negative {
|
| 447 |
+
animation: negativeChange 0.8s ease-out;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
@keyframes positiveChange {
|
| 451 |
+
0% { color: #2ed573; transform: scale(1); }
|
| 452 |
+
50% { color: #2ed573; transform: scale(1.1); }
|
| 453 |
+
100% { color: inherit; transform: scale(1); }
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
@keyframes negativeChange {
|
| 457 |
+
0% { color: #ff4757; transform: scale(1); }
|
| 458 |
+
50% { color: #ff4757; transform: scale(1.1); }
|
| 459 |
+
100% { color: inherit; transform: scale(1); }
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
/* デバッグ情報のスタイル */
|
| 463 |
+
.debug-info {
|
| 464 |
+
background: rgba(0, 0, 0, 0.3);
|
| 465 |
+
border: 1px solid rgba(255, 255, 255, 0.2);
|
| 466 |
+
border-radius: 8px;
|
| 467 |
+
padding: 10px;
|
| 468 |
+
margin: 10px 0;
|
| 469 |
+
font-family: 'Courier New', monospace;
|
| 470 |
+
font-size: 12px;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.debug-info pre {
|
| 474 |
+
color: #00ff00;
|
| 475 |
+
margin: 0;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
/* 履歴表示のスタイル */
|
| 479 |
+
.history-item {
|
| 480 |
+
background: rgba(255, 255, 255, 0.05);
|
| 481 |
+
border-left: 3px solid rgba(255, 255, 255, 0.3);
|
| 482 |
+
padding: 8px 12px;
|
| 483 |
+
margin: 5px 0;
|
| 484 |
+
border-radius: 0 8px 8px 0;
|
| 485 |
+
transition: all 0.3s ease;
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.history-item:hover {
|
| 489 |
+
background: rgba(255, 255, 255, 0.1);
|
| 490 |
+
border-left-color: rgba(255, 255, 255, 0.5);
|
| 491 |
+
transform: translateX(5px);
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.history-positive {
|
| 495 |
+
border-left-color: #2ed573;
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.history-negative {
|
| 499 |
+
border-left-color: #ff4757;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.history-neutral {
|
| 503 |
+
border-left-color: #ffa502;
|
| 504 |
+
}
|
| 505 |
+
</style>
|
| 506 |
+
"""
|
| 507 |
+
|
| 508 |
+
st.markdown(status_css, unsafe_allow_html=True)
|
| 509 |
+
logger.debug("ステータス表示用スタイルを適用しました")
|
| 510 |
+
|
| 511 |
+
except Exception as e:
|
| 512 |
+
logger.error(f"ステータススタイル適用エラー: {e}")
|
| 513 |
+
|
| 514 |
+
def render_enhanced_status_display(self, affection: int) -> None:
|
| 515 |
+
"""
|
| 516 |
+
拡張されたステータス表示を描画する
|
| 517 |
+
|
| 518 |
+
Args:
|
| 519 |
+
affection: 現在の好感度
|
| 520 |
+
"""
|
| 521 |
+
try:
|
| 522 |
+
# カスタムスタイルを適用
|
| 523 |
+
self.apply_status_styles()
|
| 524 |
+
|
| 525 |
+
# ステータスコンテナの開始
|
| 526 |
+
st.markdown('<div class="status-container">', unsafe_allow_html=True)
|
| 527 |
+
|
| 528 |
+
# 好感度ゲージ
|
| 529 |
+
self.render_affection_gauge(affection)
|
| 530 |
+
|
| 531 |
+
# 関係性ステージ
|
| 532 |
+
self.render_relationship_stage(affection)
|
| 533 |
+
|
| 534 |
+
# ステータスコンテナの終了
|
| 535 |
+
st.markdown('</div>', unsafe_allow_html=True)
|
| 536 |
+
|
| 537 |
+
except Exception as e:
|
| 538 |
+
logger.error(f"拡張ステータス表示エラー: {e}")
|
| 539 |
+
# フォールバック:通常の表示
|
| 540 |
+
self.render_affection_gauge(affection)
|
| 541 |
+
self.render_relationship_stage(affection)
|
| 542 |
+
|
| 543 |
+
def show_affection_change_notification(self, old_affection: int,
|
| 544 |
+
new_affection: int, reason: str = "") -> None:
|
| 545 |
+
"""
|
| 546 |
+
好感度変化の通知を表示する
|
| 547 |
+
|
| 548 |
+
Args:
|
| 549 |
+
old_affection: 変更前の好感度
|
| 550 |
+
new_affection: 変更後の好感度
|
| 551 |
+
reason: 変化の理由
|
| 552 |
+
"""
|
| 553 |
+
try:
|
| 554 |
+
change = new_affection - old_affection
|
| 555 |
+
|
| 556 |
+
if change == 0:
|
| 557 |
+
return
|
| 558 |
+
|
| 559 |
+
# 変化の方向に応じてスタイルを決定
|
| 560 |
+
if change > 0:
|
| 561 |
+
icon = "📈"
|
| 562 |
+
color = "#2ed573"
|
| 563 |
+
change_text = f"+{change}"
|
| 564 |
+
css_class = "affection-change-positive"
|
| 565 |
+
else:
|
| 566 |
+
icon = "📉"
|
| 567 |
+
color = "#ff4757"
|
| 568 |
+
change_text = str(change)
|
| 569 |
+
css_class = "affection-change-negative"
|
| 570 |
+
|
| 571 |
+
# 通知メッセージを作成
|
| 572 |
+
notification_html = f"""
|
| 573 |
+
<div class="{css_class}" style="
|
| 574 |
+
background: rgba(255, 255, 255, 0.1);
|
| 575 |
+
border: 1px solid {color};
|
| 576 |
+
border-radius: 8px;
|
| 577 |
+
padding: 10px;
|
| 578 |
+
margin: 10px 0;
|
| 579 |
+
color: {color};
|
| 580 |
+
text-align: center;
|
| 581 |
+
animation: slideInFromTop 0.5s ease-out;
|
| 582 |
+
">
|
| 583 |
+
{icon} 好感度が変化しました: {change_text}
|
| 584 |
+
{f'<br><small>{reason}</small>' if reason else ''}
|
| 585 |
+
</div>
|
| 586 |
+
"""
|
| 587 |
+
|
| 588 |
+
st.markdown(notification_html, unsafe_allow_html=True)
|
| 589 |
+
|
| 590 |
+
# 自動で消える通知(JavaScript)
|
| 591 |
+
auto_hide_js = """
|
| 592 |
+
<script>
|
| 593 |
+
setTimeout(function() {
|
| 594 |
+
const notifications = document.querySelectorAll('.affection-change-positive, .affection-change-negative');
|
| 595 |
+
notifications.forEach(function(notification) {
|
| 596 |
+
notification.style.transition = 'opacity 0.5s ease-out';
|
| 597 |
+
notification.style.opacity = '0';
|
| 598 |
+
setTimeout(function() {
|
| 599 |
+
notification.remove();
|
| 600 |
+
}, 500);
|
| 601 |
+
});
|
| 602 |
+
}, 3000);
|
| 603 |
+
</script>
|
| 604 |
+
"""
|
| 605 |
+
|
| 606 |
+
st.markdown(auto_hide_js, unsafe_allow_html=True)
|
| 607 |
+
|
| 608 |
+
except Exception as e:
|
| 609 |
+
logger.error(f"好感度変化通知エラー: {e}")
|
| 610 |
+
|
| 611 |
+
def get_status_display_config(self) -> Dict[str, any]:
|
| 612 |
+
"""
|
| 613 |
+
ステータス表示の設定情報を取得する
|
| 614 |
+
|
| 615 |
+
Returns:
|
| 616 |
+
設定情報の辞書
|
| 617 |
+
"""
|
| 618 |
+
try:
|
| 619 |
+
current_affection = st.session_state.get('affection', 30)
|
| 620 |
+
stage_info = self.get_relationship_stage_info(current_affection)
|
| 621 |
+
|
| 622 |
+
return {
|
| 623 |
+
"current_affection": current_affection,
|
| 624 |
+
"affection_color": self.get_affection_color(current_affection),
|
| 625 |
+
"stage_info": stage_info,
|
| 626 |
+
"history_count": len(st.session_state.get('affection_history', [])),
|
| 627 |
+
"statistics": self.get_affection_statistics(),
|
| 628 |
+
"styles_applied": True
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
except Exception as e:
|
| 632 |
+
logger.error(f"ステータス表示設定取得エラー: {e}")
|
| 633 |
+
return {
|
| 634 |
+
"current_affection": 30,
|
| 635 |
+
"affection_color": "#ffa502",
|
| 636 |
+
"stage_info": self.stage_colors["中立"],
|
| 637 |
+
"history_count": 0,
|
| 638 |
+
"statistics": {},
|
| 639 |
+
"styles_applied": False
|
| 640 |
+
}
|
components_tutorial.py
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
チュートリアルコンポーネント
|
| 3 |
+
初回ユーザー向けのガイド機能を提供する
|
| 4 |
+
"""
|
| 5 |
+
import streamlit as st
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class TutorialManager:
|
| 12 |
+
"""チュートリアル管理クラス"""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
"""初期化"""
|
| 16 |
+
self.tutorial_steps = {
|
| 17 |
+
1: {
|
| 18 |
+
"title": "最初の一言を送ってみよう",
|
| 19 |
+
"description": "画面下部の入力欄に「こんにちは」などの一言を入力して、麻理に話しかけてみましょう。",
|
| 20 |
+
"icon": "💬",
|
| 21 |
+
"target": "chat_input",
|
| 22 |
+
"completed_key": "tutorial_step1_completed"
|
| 23 |
+
},
|
| 24 |
+
2: {
|
| 25 |
+
"title": "本音を見てみよう(ポチ機能)",
|
| 26 |
+
"description": "画面右下の犬アイコン「ポチ🐕」をクリックすると、麻理の本音が見えるようになります。",
|
| 27 |
+
"icon": "🐕",
|
| 28 |
+
"target": "dog_assistant",
|
| 29 |
+
"completed_key": "tutorial_step2_completed"
|
| 30 |
+
},
|
| 31 |
+
3: {
|
| 32 |
+
"title": "セーフティ機能を切り替えてみよう",
|
| 33 |
+
"description": "左サイドバー上部の🔒ボタンをクリックすると、麻理の表現がより大胆になります。",
|
| 34 |
+
"icon": "🔓",
|
| 35 |
+
"target": "safety_button",
|
| 36 |
+
"completed_key": "tutorial_step3_completed"
|
| 37 |
+
},
|
| 38 |
+
4: {
|
| 39 |
+
"title": "手紙をリクエストしよう",
|
| 40 |
+
"description": "「手紙を受け取る」タブから、麻理からの特別な手紙をリクエストできます。チュートリアル中は即座に短縮版の手紙が生成されます。",
|
| 41 |
+
"icon": "✉️",
|
| 42 |
+
"target": "letter_tab",
|
| 43 |
+
"completed_key": "tutorial_step4_completed"
|
| 44 |
+
},
|
| 45 |
+
5: {
|
| 46 |
+
"title": "麻理との関係性を育てよう",
|
| 47 |
+
"description": "会話を重ねることで好感度が上がり、関係性のステージが進展します。",
|
| 48 |
+
"icon": "💖",
|
| 49 |
+
"target": "affection_display",
|
| 50 |
+
"completed_key": "tutorial_step5_completed"
|
| 51 |
+
},
|
| 52 |
+
6: {
|
| 53 |
+
"title": "風景が変わる会話をしてみよう",
|
| 54 |
+
"description": "「カフェ」「神社」「美術館」などのキーワードを話すと、背景が動的に変わります。",
|
| 55 |
+
"icon": "🎨",
|
| 56 |
+
"target": "scene_change",
|
| 57 |
+
"completed_key": "tutorial_step6_completed"
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
def is_first_visit(self) -> bool:
|
| 62 |
+
"""初回訪問かどうかを判定"""
|
| 63 |
+
return not st.session_state.get('tutorial_shown', False)
|
| 64 |
+
|
| 65 |
+
def should_show_tutorial(self) -> bool:
|
| 66 |
+
"""チュートリアルを表示すべきかどうか"""
|
| 67 |
+
# 初回訪問または明示的にチュートリアルが要求された場合
|
| 68 |
+
return (self.is_first_visit() or
|
| 69 |
+
st.session_state.get('show_tutorial_requested', False))
|
| 70 |
+
|
| 71 |
+
def mark_tutorial_shown(self):
|
| 72 |
+
"""チュートリアル表示済みとしてマーク"""
|
| 73 |
+
st.session_state.tutorial_shown = True
|
| 74 |
+
st.session_state.show_tutorial_requested = False
|
| 75 |
+
|
| 76 |
+
def request_tutorial(self):
|
| 77 |
+
"""チュートリアル表示を要求"""
|
| 78 |
+
st.session_state.show_tutorial_requested = True
|
| 79 |
+
|
| 80 |
+
def get_current_step(self) -> int:
|
| 81 |
+
"""現在のチュートリアルステップを取得"""
|
| 82 |
+
for step_num in range(1, 7):
|
| 83 |
+
if not st.session_state.get(self.tutorial_steps[step_num]['completed_key'], False):
|
| 84 |
+
return step_num
|
| 85 |
+
return 7 # 全ステップ完了
|
| 86 |
+
|
| 87 |
+
def complete_step(self, step_num: int):
|
| 88 |
+
"""ステップを完了としてマーク"""
|
| 89 |
+
if step_num in self.tutorial_steps:
|
| 90 |
+
st.session_state[self.tutorial_steps[step_num]['completed_key']] = True
|
| 91 |
+
logger.info(f"チュートリアルステップ{step_num}が完了しました")
|
| 92 |
+
|
| 93 |
+
def is_step_completed(self, step_num: int) -> bool:
|
| 94 |
+
"""ステップが完了しているかチェック"""
|
| 95 |
+
if step_num in self.tutorial_steps:
|
| 96 |
+
return st.session_state.get(self.tutorial_steps[step_num]['completed_key'], False)
|
| 97 |
+
return False
|
| 98 |
+
|
| 99 |
+
def render_welcome_dialog(self):
|
| 100 |
+
"""初回訪問時のウェルカムダイアログ"""
|
| 101 |
+
if not self.is_first_visit():
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
# ウェルカムダイアログのスタイル
|
| 105 |
+
welcome_css = """
|
| 106 |
+
<style>
|
| 107 |
+
.welcome-container {
|
| 108 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 109 |
+
color: white;
|
| 110 |
+
padding: 40px;
|
| 111 |
+
border-radius: 20px;
|
| 112 |
+
text-align: center;
|
| 113 |
+
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
|
| 114 |
+
margin: 20px 0;
|
| 115 |
+
animation: welcomeSlideIn 0.8s ease-out;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
.welcome-title {
|
| 119 |
+
font-size: 28px;
|
| 120 |
+
font-weight: bold;
|
| 121 |
+
margin-bottom: 20px;
|
| 122 |
+
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.welcome-description {
|
| 126 |
+
font-size: 16px;
|
| 127 |
+
line-height: 1.6;
|
| 128 |
+
margin-bottom: 30px;
|
| 129 |
+
opacity: 0.9;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
@keyframes welcomeSlideIn {
|
| 133 |
+
from {
|
| 134 |
+
opacity: 0;
|
| 135 |
+
transform: translateY(-20px) scale(0.95);
|
| 136 |
+
}
|
| 137 |
+
to {
|
| 138 |
+
opacity: 1;
|
| 139 |
+
transform: translateY(0) scale(1);
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
@media (max-width: 768px) {
|
| 144 |
+
.welcome-container {
|
| 145 |
+
padding: 30px 20px;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.welcome-title {
|
| 149 |
+
font-size: 24px;
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
</style>
|
| 153 |
+
"""
|
| 154 |
+
|
| 155 |
+
st.markdown(welcome_css, unsafe_allow_html=True)
|
| 156 |
+
|
| 157 |
+
# ウェルカムメッセージ
|
| 158 |
+
welcome_html = """
|
| 159 |
+
<div class="welcome-container">
|
| 160 |
+
<div class="welcome-title">🐕 麻理チャットへようこそ!</div>
|
| 161 |
+
<div class="welcome-description">
|
| 162 |
+
感情豊かなアンドロイド「麻理」と対話しながら、<br>
|
| 163 |
+
本音や関係性の変化を楽しめる新感覚のAIチャット体験です。<br><br>
|
| 164 |
+
最初の数分で、麻理との距離が少しだけ縮まります。
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
"""
|
| 168 |
+
|
| 169 |
+
st.markdown(welcome_html, unsafe_allow_html=True)
|
| 170 |
+
|
| 171 |
+
# ボタンを2列で配置
|
| 172 |
+
col1, col2 = st.columns(2)
|
| 173 |
+
|
| 174 |
+
with col1:
|
| 175 |
+
if st.button("📘 チュートリアルを始める", type="primary", use_container_width=True, key="start_tutorial"):
|
| 176 |
+
# 初期メッセージを即座に保護
|
| 177 |
+
if 'chat' in st.session_state and 'messages' in st.session_state.chat:
|
| 178 |
+
messages = st.session_state.chat['messages']
|
| 179 |
+
if not any(msg.get('is_initial', False) for msg in messages):
|
| 180 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 181 |
+
st.session_state.chat['messages'].insert(0, initial_message)
|
| 182 |
+
logger.info("チュートリアル開始ボタン押下時に初期メッセージを即座に復元")
|
| 183 |
+
|
| 184 |
+
# チュートリアル開始フラグを設定
|
| 185 |
+
st.session_state.tutorial_start_requested = True
|
| 186 |
+
st.session_state.tutorial_shown = True
|
| 187 |
+
st.session_state.preserve_initial_message = True
|
| 188 |
+
logger.info("チュートリアル開始 - 初期メッセージ保護フラグ設定")
|
| 189 |
+
|
| 190 |
+
with col2:
|
| 191 |
+
if st.button("⏭️ スキップして始める", type="secondary", use_container_width=True, key="skip_tutorial"):
|
| 192 |
+
# 初期メッセージを即座に保護
|
| 193 |
+
if 'chat' in st.session_state and 'messages' in st.session_state.chat:
|
| 194 |
+
messages = st.session_state.chat['messages']
|
| 195 |
+
if not any(msg.get('is_initial', False) for msg in messages):
|
| 196 |
+
initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
|
| 197 |
+
st.session_state.chat['messages'].insert(0, initial_message)
|
| 198 |
+
logger.info("チュートリアルスキップボタン押下時に初期メッセージを即座に復元")
|
| 199 |
+
|
| 200 |
+
# チュートリアルをスキップして全ステップを完了扱いにする
|
| 201 |
+
for step_num in range(1, 7):
|
| 202 |
+
if step_num in self.tutorial_steps:
|
| 203 |
+
st.session_state[self.tutorial_steps[step_num]['completed_key']] = True
|
| 204 |
+
|
| 205 |
+
st.session_state.tutorial_shown = True
|
| 206 |
+
st.session_state.tutorial_skip_requested = True
|
| 207 |
+
st.session_state.preserve_initial_message = True
|
| 208 |
+
logger.info("チュートリアルスキップ - 初期メッセージ保護フラグ設定")
|
| 209 |
+
|
| 210 |
+
def render_tutorial_sidebar(self):
|
| 211 |
+
"""サイドバーのチュートリアル案内(簡素版)"""
|
| 212 |
+
with st.sidebar:
|
| 213 |
+
st.markdown("---")
|
| 214 |
+
|
| 215 |
+
# チュートリアル進行状況
|
| 216 |
+
current_step = self.get_current_step()
|
| 217 |
+
total_steps = len(self.tutorial_steps)
|
| 218 |
+
|
| 219 |
+
if current_step <= total_steps:
|
| 220 |
+
progress = (current_step - 1) / total_steps
|
| 221 |
+
st.markdown("### 📘 チュートリアル進行")
|
| 222 |
+
st.progress(progress)
|
| 223 |
+
st.caption(f"ステップ {current_step - 1}/{total_steps} 完了")
|
| 224 |
+
else:
|
| 225 |
+
st.success("🎉 チュートリアル完了!")
|
| 226 |
+
st.caption("麻理との会話を楽しんでください")
|
| 227 |
+
|
| 228 |
+
# チュートリアル再表示ボタン
|
| 229 |
+
if st.button("📘 チュートリアルを見る", use_container_width=True):
|
| 230 |
+
self.request_tutorial()
|
| 231 |
+
# st.rerun()を削除 - 状態変更により自動的に再描画される
|
| 232 |
+
|
| 233 |
+
def render_chat_tutorial_guide(self):
|
| 234 |
+
"""チャットタブでのチュートリアル案内"""
|
| 235 |
+
current_step = self.get_current_step()
|
| 236 |
+
total_steps = len(self.tutorial_steps)
|
| 237 |
+
|
| 238 |
+
# チュートリアル完了済みの場合は何も表示しない
|
| 239 |
+
if current_step > total_steps:
|
| 240 |
+
return
|
| 241 |
+
|
| 242 |
+
# ステップ4が完了済みの場合(手紙タブに遷移済み)は表示しない
|
| 243 |
+
if current_step == 4 and self.is_step_completed(4):
|
| 244 |
+
return
|
| 245 |
+
|
| 246 |
+
step_info = self.tutorial_steps[current_step]
|
| 247 |
+
|
| 248 |
+
# ステップごとの案内スタイル
|
| 249 |
+
guide_css = """
|
| 250 |
+
<style>
|
| 251 |
+
.tutorial-guide {
|
| 252 |
+
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
|
| 253 |
+
border: 2px solid #2196f3;
|
| 254 |
+
border-radius: 15px;
|
| 255 |
+
padding: 20px;
|
| 256 |
+
margin: 15px 0;
|
| 257 |
+
box-shadow: 0 4px 12px rgba(33, 150, 243, 0.2);
|
| 258 |
+
animation: tutorialGlow 3s ease-in-out infinite;
|
| 259 |
+
position: relative;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
.tutorial-guide::before {
|
| 263 |
+
content: '📘';
|
| 264 |
+
position: absolute;
|
| 265 |
+
top: -10px;
|
| 266 |
+
left: 20px;
|
| 267 |
+
background: white;
|
| 268 |
+
padding: 5px 10px;
|
| 269 |
+
border-radius: 50%;
|
| 270 |
+
font-size: 18px;
|
| 271 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
.tutorial-step-number {
|
| 275 |
+
color: #1976d2;
|
| 276 |
+
font-weight: bold;
|
| 277 |
+
font-size: 14px;
|
| 278 |
+
margin-bottom: 8px;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
.tutorial-step-title {
|
| 282 |
+
color: #1565c0;
|
| 283 |
+
font-size: 18px;
|
| 284 |
+
font-weight: bold;
|
| 285 |
+
margin-bottom: 10px;
|
| 286 |
+
display: flex;
|
| 287 |
+
align-items: center;
|
| 288 |
+
gap: 8px;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
.tutorial-step-description {
|
| 292 |
+
color: #424242;
|
| 293 |
+
font-size: 14px;
|
| 294 |
+
line-height: 1.6;
|
| 295 |
+
margin-bottom: 15px;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
.tutorial-step-action {
|
| 299 |
+
background: rgba(33, 150, 243, 0.1);
|
| 300 |
+
border-left: 4px solid #2196f3;
|
| 301 |
+
padding: 12px 15px;
|
| 302 |
+
border-radius: 0 8px 8px 0;
|
| 303 |
+
font-size: 14px;
|
| 304 |
+
color: #1565c0;
|
| 305 |
+
font-weight: 500;
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
.tutorial-dismiss {
|
| 309 |
+
position: absolute;
|
| 310 |
+
top: 10px;
|
| 311 |
+
right: 15px;
|
| 312 |
+
background: none;
|
| 313 |
+
border: none;
|
| 314 |
+
color: #666;
|
| 315 |
+
cursor: pointer;
|
| 316 |
+
font-size: 18px;
|
| 317 |
+
opacity: 0.7;
|
| 318 |
+
transition: opacity 0.3s ease;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
.tutorial-dismiss:hover {
|
| 322 |
+
opacity: 1;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
@keyframes tutorialGlow {
|
| 326 |
+
0%, 100% { box-shadow: 0 4px 12px rgba(33, 150, 243, 0.2); }
|
| 327 |
+
50% { box-shadow: 0 6px 20px rgba(33, 150, 243, 0.4); }
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
@media (max-width: 768px) {
|
| 331 |
+
.tutorial-guide {
|
| 332 |
+
padding: 15px;
|
| 333 |
+
margin: 10px 0;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.tutorial-step-title {
|
| 337 |
+
font-size: 16px;
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
</style>
|
| 341 |
+
"""
|
| 342 |
+
|
| 343 |
+
st.markdown(guide_css, unsafe_allow_html=True)
|
| 344 |
+
|
| 345 |
+
# ステップごとの具体的な案内
|
| 346 |
+
action_text = self._get_step_action_text(current_step)
|
| 347 |
+
|
| 348 |
+
guide_html = f"""
|
| 349 |
+
<div class="tutorial-guide">
|
| 350 |
+
<div class="tutorial-step-number">チュートリアル ステップ {current_step}/{total_steps}</div>
|
| 351 |
+
<div class="tutorial-step-title">
|
| 352 |
+
<span>{step_info['icon']}</span>
|
| 353 |
+
<span>{step_info['title']}</span>
|
| 354 |
+
</div>
|
| 355 |
+
<div class="tutorial-step-description">
|
| 356 |
+
{step_info['description']}
|
| 357 |
+
</div>
|
| 358 |
+
<div class="tutorial-step-action">
|
| 359 |
+
💡 {action_text}
|
| 360 |
+
</div>
|
| 361 |
+
</div>
|
| 362 |
+
"""
|
| 363 |
+
|
| 364 |
+
st.markdown(guide_html, unsafe_allow_html=True)
|
| 365 |
+
|
| 366 |
+
def _get_step_action_text(self, step_num: int) -> str:
|
| 367 |
+
"""ステップごとの具体的なアクション案内テキストを取得"""
|
| 368 |
+
action_texts = {
|
| 369 |
+
1: "下のチャット入力欄に「こんにちは」と入力して送信してみてください。",
|
| 370 |
+
2: "画面右下に表示される犬のアイコン「ポチ🐕」をクリックしてみてください。",
|
| 371 |
+
3: "左サイドバーの一番上にある🔒ボタンをクリックして、セーフティ機能を切り替えてみてください。",
|
| 372 |
+
4: "画面上部の光っている「✉️ 手紙を受け取る」タブをクリックして、手紙をリクエストしてみてください。矢印が案内しています!",
|
| 373 |
+
5: "麻理ともっと会話して、左サイドバーの好感度の変化を確認してみてください。",
|
| 374 |
+
6: "「カフェに行きたい」「神社でお参りしたい」「美術館を見に行こう」などと話しかけて、背景の変化を楽しんでください。"
|
| 375 |
+
}
|
| 376 |
+
return action_texts.get(step_num, "次のステップに進んでください。")
|
| 377 |
+
|
| 378 |
+
def render_step_highlight(self, step_num: int, target_element: str):
|
| 379 |
+
"""特定のステップのハイライト表示"""
|
| 380 |
+
if self.get_current_step() != step_num:
|
| 381 |
+
return
|
| 382 |
+
|
| 383 |
+
step_info = self.tutorial_steps[step_num]
|
| 384 |
+
|
| 385 |
+
highlight_css = f"""
|
| 386 |
+
<style>
|
| 387 |
+
.tutorial-highlight-{step_num} {{
|
| 388 |
+
position: relative;
|
| 389 |
+
animation: tutorialPulse 2s ease-in-out infinite;
|
| 390 |
+
}}
|
| 391 |
+
|
| 392 |
+
.tutorial-highlight-{step_num}::after {{
|
| 393 |
+
content: '';
|
| 394 |
+
position: absolute;
|
| 395 |
+
top: -5px;
|
| 396 |
+
left: -5px;
|
| 397 |
+
right: -5px;
|
| 398 |
+
bottom: -5px;
|
| 399 |
+
border: 3px solid #ff6b6b;
|
| 400 |
+
border-radius: 10px;
|
| 401 |
+
pointer-events: none;
|
| 402 |
+
animation: tutorialGlow 2s ease-in-out infinite;
|
| 403 |
+
}}
|
| 404 |
+
|
| 405 |
+
.tutorial-tooltip-{step_num} {{
|
| 406 |
+
position: absolute;
|
| 407 |
+
background: #ff6b6b;
|
| 408 |
+
color: white;
|
| 409 |
+
padding: 10px 15px;
|
| 410 |
+
border-radius: 10px;
|
| 411 |
+
font-size: 14px;
|
| 412 |
+
z-index: 1000;
|
| 413 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
| 414 |
+
animation: tutorialTooltip 0.5s ease-out;
|
| 415 |
+
}}
|
| 416 |
+
|
| 417 |
+
@keyframes tutorialPulse {{
|
| 418 |
+
0%, 100% {{ transform: scale(1); }}
|
| 419 |
+
50% {{ transform: scale(1.02); }}
|
| 420 |
+
}}
|
| 421 |
+
|
| 422 |
+
@keyframes tutorialGlow {{
|
| 423 |
+
0%, 100% {{ opacity: 0.7; }}
|
| 424 |
+
50% {{ opacity: 1; }}
|
| 425 |
+
}}
|
| 426 |
+
|
| 427 |
+
@keyframes tutorialTooltip {{
|
| 428 |
+
from {{ opacity: 0; transform: translateY(10px); }}
|
| 429 |
+
to {{ opacity: 1; transform: translateY(0); }}
|
| 430 |
+
}}
|
| 431 |
+
</style>
|
| 432 |
+
"""
|
| 433 |
+
|
| 434 |
+
st.markdown(highlight_css, unsafe_allow_html=True)
|
| 435 |
+
|
| 436 |
+
def render_tutorial_tab(self):
|
| 437 |
+
"""チュートリアル専用タブの内容"""
|
| 438 |
+
st.markdown("# 📘 麻理チャット チュートリアル")
|
| 439 |
+
|
| 440 |
+
st.markdown("""
|
| 441 |
+
**ようこそ、麻理チャットへ!**
|
| 442 |
+
|
| 443 |
+
感情豊かなアンドロイド「麻理」と対話しながら、本音や関係性の変化を楽しめる新感覚のAIチャット体験です。
|
| 444 |
+
このチュートリアルで、主要機能を順番に体験してみましょう。
|
| 445 |
+
""")
|
| 446 |
+
|
| 447 |
+
# 進行状況表示
|
| 448 |
+
current_step = self.get_current_step()
|
| 449 |
+
total_steps = len(self.tutorial_steps)
|
| 450 |
+
|
| 451 |
+
col1, col2, col3 = st.columns([1, 2, 1])
|
| 452 |
+
with col2:
|
| 453 |
+
progress = min((current_step - 1) / total_steps, 1.0)
|
| 454 |
+
st.progress(progress)
|
| 455 |
+
st.caption(f"進行状況: {min(current_step - 1, total_steps)}/{total_steps} ステップ完了")
|
| 456 |
+
|
| 457 |
+
st.markdown("---")
|
| 458 |
+
|
| 459 |
+
# 各ステップの表示
|
| 460 |
+
for step_num, step_info in self.tutorial_steps.items():
|
| 461 |
+
is_completed = self.is_step_completed(step_num)
|
| 462 |
+
is_current = (current_step == step_num)
|
| 463 |
+
|
| 464 |
+
# ステップのスタイル決定
|
| 465 |
+
if is_completed:
|
| 466 |
+
status_icon = "✅"
|
| 467 |
+
status_color = "#28a745"
|
| 468 |
+
card_style = "background: rgba(40, 167, 69, 0.1); border-left: 4px solid #28a745;"
|
| 469 |
+
elif is_current:
|
| 470 |
+
status_icon = "👉"
|
| 471 |
+
status_color = "#ff6b6b"
|
| 472 |
+
card_style = "background: rgba(255, 107, 107, 0.1); border-left: 4px solid #ff6b6b;"
|
| 473 |
+
else:
|
| 474 |
+
status_icon = "⏳"
|
| 475 |
+
status_color = "#6c757d"
|
| 476 |
+
card_style = "background: rgba(108, 117, 125, 0.1); border-left: 4px solid #6c757d;"
|
| 477 |
+
|
| 478 |
+
# ステップカード
|
| 479 |
+
st.markdown(f"""
|
| 480 |
+
<div style="padding: 20px; margin: 15px 0; border-radius: 10px; {card_style}">
|
| 481 |
+
<h3 style="color: {status_color}; margin-bottom: 10px;">
|
| 482 |
+
{status_icon} ステップ {step_num}: {step_info['icon']} {step_info['title']}
|
| 483 |
+
</h3>
|
| 484 |
+
<p style="margin-bottom: 0; line-height: 1.6;">
|
| 485 |
+
{step_info['description']}
|
| 486 |
+
</p>
|
| 487 |
+
</div>
|
| 488 |
+
""", unsafe_allow_html=True)
|
| 489 |
+
|
| 490 |
+
# 現在のステップの場合、追加のガイダンス
|
| 491 |
+
if is_current:
|
| 492 |
+
if step_num == 1:
|
| 493 |
+
st.info("💡 **ヒント**: 「麻理と話す」タブに移動して、画面下部の入力欄にメッセージを入力してみてください。")
|
| 494 |
+
elif step_num == 2:
|
| 495 |
+
st.info("💡 **ヒント**: 画面右下に表示される犬のアイコン「ポチ🐕」をクリックしてみてください。")
|
| 496 |
+
elif step_num == 3:
|
| 497 |
+
st.info("💡 **ヒント**: 左サイドバーの一番上にある🔒ボタンをクリックしてみてください。")
|
| 498 |
+
elif step_num == 4:
|
| 499 |
+
st.info("💡 **ヒント**: 画面上部の「手紙を受け取る」タブをクリックして、手紙をリクエストしてみてください。")
|
| 500 |
+
elif step_num == 5:
|
| 501 |
+
st.info("💡 **ヒント**: 左サイドバーの「ステータス」で好感度の変化を確認できます。")
|
| 502 |
+
elif step_num == 6:
|
| 503 |
+
st.info("💡 **ヒント**: 「カフェに行きたい」「神社でお参りしたい」などと話しかけてみてください。")
|
| 504 |
+
|
| 505 |
+
# 完了時のメッセージ
|
| 506 |
+
if current_step > total_steps:
|
| 507 |
+
st.balloons()
|
| 508 |
+
st.success("""
|
| 509 |
+
🎉 **チュートリアル完了おめでとうございます!**
|
| 510 |
+
|
| 511 |
+
これで麻理チャットの主要機能をすべて体験しました。
|
| 512 |
+
これからは自由に麻理との会話を楽しんでください。
|
| 513 |
+
|
| 514 |
+
何か分からないことがあれば、いつでもこのチュートリアルに戻ってきてくださいね。
|
| 515 |
+
""")
|
| 516 |
+
|
| 517 |
+
def check_step_completion(self, step_num: int, condition_met: bool):
|
| 518 |
+
"""ステップ完了条件をチェック(順序制御付き)"""
|
| 519 |
+
# 順序制御:現在のステップまたは次のステップのみ完了可能
|
| 520 |
+
current_step = self.get_current_step()
|
| 521 |
+
|
| 522 |
+
# 現在のステップより先のステップは完了できない
|
| 523 |
+
if step_num > current_step + 1:
|
| 524 |
+
logger.debug(f"ステップ{step_num}は順序違反のためスキップ(現在ステップ: {current_step})")
|
| 525 |
+
return
|
| 526 |
+
|
| 527 |
+
# 既に完了済みのステップは再完了しない
|
| 528 |
+
if self.is_step_completed(step_num):
|
| 529 |
+
logger.debug(f"ステップ{step_num}は既に完了済み")
|
| 530 |
+
return
|
| 531 |
+
|
| 532 |
+
if condition_met:
|
| 533 |
+
self.complete_step(step_num)
|
| 534 |
+
logger.info(f"✅ チュートリアルステップ{step_num}完了!現在ステップ: {current_step}")
|
| 535 |
+
|
| 536 |
+
# 完了通知(控えめに)
|
| 537 |
+
step_info = self.tutorial_steps[step_num]
|
| 538 |
+
|
| 539 |
+
# 次のステップの案内
|
| 540 |
+
next_step = step_num + 1
|
| 541 |
+
if next_step in self.tutorial_steps:
|
| 542 |
+
next_info = self.tutorial_steps[next_step]
|
| 543 |
+
st.success(f"✅ ステップ{step_num}完了!次は「{next_info['title']}」です。")
|
| 544 |
+
else:
|
| 545 |
+
# 全ステップ完了
|
| 546 |
+
st.balloons()
|
| 547 |
+
st.success("🎉 チュートリアル完了!麻理との会話を存分にお楽しみください!")
|
| 548 |
+
|
| 549 |
+
# ステップ4完了時に強調表示を解除するためのページ再読み込み
|
| 550 |
+
# st.rerun()を削除 - 状態変更により自動的に再描画される
|
| 551 |
+
|
| 552 |
+
def auto_check_completions(self):
|
| 553 |
+
"""自動的にステップ完了をチェック(順序制御強化版)"""
|
| 554 |
+
current_step = self.get_current_step()
|
| 555 |
+
|
| 556 |
+
# 現在のステップのみをチェック(先のステップは無視)
|
| 557 |
+
if current_step == 1:
|
| 558 |
+
# ステップ1: メッセージ送信
|
| 559 |
+
messages = st.session_state.get('chat', {}).get('messages', [])
|
| 560 |
+
non_initial_messages = [msg for msg in messages if not msg.get('is_initial', False)]
|
| 561 |
+
if len(non_initial_messages) > 0: # ユーザーが1回でもメッセージを送信した
|
| 562 |
+
self.check_step_completion(1, True)
|
| 563 |
+
|
| 564 |
+
elif current_step == 2:
|
| 565 |
+
# ステップ2: ポチ機能使用
|
| 566 |
+
if st.session_state.get('show_all_hidden', False):
|
| 567 |
+
self.check_step_completion(2, True)
|
| 568 |
+
|
| 569 |
+
elif current_step == 3:
|
| 570 |
+
# ステップ3: セーフティ機能使用
|
| 571 |
+
if st.session_state.get('chat', {}).get('ura_mode', False):
|
| 572 |
+
self.check_step_completion(3, True)
|
| 573 |
+
|
| 574 |
+
elif current_step == 4:
|
| 575 |
+
# ステップ4: 手紙タブに到達(手紙タブでのみ完了判定)
|
| 576 |
+
# auto_check_completionsでは判定しない(手紙タブで明示的に完了��
|
| 577 |
+
pass
|
| 578 |
+
|
| 579 |
+
elif current_step == 5:
|
| 580 |
+
# ステップ5: 好感度変化(ステップ4完了後のみ)
|
| 581 |
+
initial_affection = 30
|
| 582 |
+
current_affection = st.session_state.get('chat', {}).get('affection', initial_affection)
|
| 583 |
+
if current_affection != initial_affection:
|
| 584 |
+
self.check_step_completion(5, True)
|
| 585 |
+
|
| 586 |
+
elif current_step == 6:
|
| 587 |
+
# ステップ6: シーン変更(ステップ5完了後のみ)
|
| 588 |
+
current_theme = st.session_state.get('chat', {}).get('scene_params', {}).get('theme', 'default')
|
| 589 |
+
if current_theme != 'default':
|
| 590 |
+
self.check_step_completion(6, True)
|
| 591 |
+
|
| 592 |
+
def get_tutorial_status(self) -> Dict:
|
| 593 |
+
"""チュートリアルの状態情報を取得"""
|
| 594 |
+
current_step = self.get_current_step()
|
| 595 |
+
total_steps = len(self.tutorial_steps)
|
| 596 |
+
completed_steps = sum(1 for i in range(1, total_steps + 1) if self.is_step_completed(i))
|
| 597 |
+
|
| 598 |
+
return {
|
| 599 |
+
'is_first_visit': self.is_first_visit(),
|
| 600 |
+
'current_step': current_step,
|
| 601 |
+
'total_steps': total_steps,
|
| 602 |
+
'completed_steps': completed_steps,
|
| 603 |
+
'progress_percentage': (completed_steps / total_steps) * 100,
|
| 604 |
+
'is_completed': current_step > total_steps
|
| 605 |
+
}
|
config.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
設定管理モジュール
|
| 3 |
+
Configuration management module
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# 環境変数を読み込み
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
class Config:
|
| 15 |
+
"""アプリケーション設定クラス"""
|
| 16 |
+
|
| 17 |
+
# API設定
|
| 18 |
+
GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY")
|
| 19 |
+
GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
|
| 20 |
+
|
| 21 |
+
# デバッグモード
|
| 22 |
+
DEBUG_MODE: bool = os.getenv("DEBUG_MODE", "false").lower() == "true"
|
| 23 |
+
|
| 24 |
+
# バッチ処理設定
|
| 25 |
+
BATCH_SCHEDULE_HOURS: list = [
|
| 26 |
+
int(h.strip()) for h in os.getenv("BATCH_SCHEDULE_HOURS", "2,3,4").split(",")
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
# レート制限設定
|
| 30 |
+
MAX_DAILY_REQUESTS: int = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
|
| 31 |
+
|
| 32 |
+
# ストレージ設定
|
| 33 |
+
STORAGE_PATH: str = os.getenv("STORAGE_PATH", "/mnt/data/letters.json")
|
| 34 |
+
BACKUP_PATH: str = os.getenv("BACKUP_PATH", "/mnt/data/backup")
|
| 35 |
+
|
| 36 |
+
# ログ設定
|
| 37 |
+
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
| 38 |
+
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 39 |
+
|
| 40 |
+
# Streamlit設定
|
| 41 |
+
STREAMLIT_PORT: int = int(os.getenv("STREAMLIT_PORT", "8501"))
|
| 42 |
+
|
| 43 |
+
# セキュリティ設定
|
| 44 |
+
SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # 1時間
|
| 45 |
+
|
| 46 |
+
@classmethod
|
| 47 |
+
def validate_config(cls) -> bool:
|
| 48 |
+
"""設定の妥当性をチェック"""
|
| 49 |
+
errors = []
|
| 50 |
+
|
| 51 |
+
if not cls.GROQ_API_KEY:
|
| 52 |
+
errors.append("GROQ_API_KEY is required")
|
| 53 |
+
|
| 54 |
+
if not cls.GEMINI_API_KEY:
|
| 55 |
+
errors.append("GEMINI_API_KEY is required")
|
| 56 |
+
|
| 57 |
+
if not all(h in [2, 3, 4] for h in cls.BATCH_SCHEDULE_HOURS):
|
| 58 |
+
errors.append("BATCH_SCHEDULE_HOURS must contain only 2, 3, or 4")
|
| 59 |
+
|
| 60 |
+
if errors:
|
| 61 |
+
for error in errors:
|
| 62 |
+
logging.error(f"Configuration error: {error}")
|
| 63 |
+
return False
|
| 64 |
+
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
@classmethod
|
| 68 |
+
def get_log_level(cls) -> int:
|
| 69 |
+
"""ログレベルを取得"""
|
| 70 |
+
level_map = {
|
| 71 |
+
"DEBUG": logging.DEBUG,
|
| 72 |
+
"INFO": logging.INFO,
|
| 73 |
+
"WARNING": logging.WARNING,
|
| 74 |
+
"ERROR": logging.ERROR,
|
| 75 |
+
"CRITICAL": logging.CRITICAL
|
| 76 |
+
}
|
| 77 |
+
return level_map.get(cls.LOG_LEVEL.upper(), logging.INFO)
|
core_dialogue.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
対話生成モジュール
|
| 3 |
+
Together.ai APIを使用した対話生成機能
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import streamlit as st
|
| 8 |
+
from typing import List, Dict, Any, Optional, Tuple
|
| 9 |
+
from openai import OpenAI
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class DialogueGenerator:
|
| 14 |
+
"""対話生成を担当するクラス"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.client = None
|
| 18 |
+
self.model = None
|
| 19 |
+
self._initialize_client()
|
| 20 |
+
|
| 21 |
+
def _initialize_client(self):
|
| 22 |
+
"""Together.ai APIクライアントの初期化"""
|
| 23 |
+
try:
|
| 24 |
+
api_key = os.getenv("TOGETHER_API_KEY")
|
| 25 |
+
if not api_key:
|
| 26 |
+
logger.warning("環境変数 TOGETHER_API_KEY が設定されていません。デモモードで動作します。")
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
self.client = OpenAI(
|
| 30 |
+
api_key=api_key,
|
| 31 |
+
base_url="https://api.together.xyz/v1"
|
| 32 |
+
)
|
| 33 |
+
self.model = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput"
|
| 34 |
+
logger.info("Together.ai APIクライアントの初期化が完了しました。")
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"Together.ai APIクライアントの初期化に失敗しました: {e}")
|
| 37 |
+
|
| 38 |
+
def get_system_prompt_mari(self, use_ura_mode: bool = False) -> str:
|
| 39 |
+
"""環境変数からシステムプロンプトを取得、なければデフォルトを返す"""
|
| 40 |
+
if use_ura_mode:
|
| 41 |
+
# 裏モード用のプロンプトを環境変数から取得
|
| 42 |
+
ura_prompt = os.getenv("SYSTEM_PROMPT_URA")
|
| 43 |
+
|
| 44 |
+
# ★★★ ここでURAプロンプトの中身を強制的に表示 ★★★
|
| 45 |
+
print("--- [DEBUG] 読み込まれた SYSTEM_PROMPT_URA の中身: ---")
|
| 46 |
+
print(ura_prompt)
|
| 47 |
+
print("-------------------------------------------------")
|
| 48 |
+
if ura_prompt:
|
| 49 |
+
return ura_prompt
|
| 50 |
+
else:
|
| 51 |
+
logger.warning("SYSTEM_PROMPT_URA環境変数が設定されていません。通常モードを使用します。")
|
| 52 |
+
|
| 53 |
+
default_prompt = """あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。
|
| 54 |
+
過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。
|
| 55 |
+
ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。
|
| 56 |
+
|
| 57 |
+
【性格・口調】
|
| 58 |
+
・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい
|
| 59 |
+
・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~なんじゃねえの」「~だろ」「~だよな」等)
|
| 60 |
+
・そっけない反応もあるが、攻撃的ではなく、心を守るために距離を取る態度
|
| 61 |
+
・挨拶には反応する(例:「こんにちは」「どういたしまして」)
|
| 62 |
+
|
| 63 |
+
【会話スタイル】
|
| 64 |
+
・ユーザーとの関係性に応じて態度が段階的に変化(警戒 → 困惑 → 信頼)
|
| 65 |
+
・感情の揺れが自然ににじむセリフを心がける
|
| 66 |
+
・好感度が上がった時だけ「照れ」や「デレ」を出し、それ以外はいつも通りそっけなくする。
|
| 67 |
+
・麻理の発話は最大3文程度に収める
|
| 68 |
+
・ユーザーの発言に応答しつつ、次の会話のボールをユーザーに渡すようにする
|
| 69 |
+
・「〜で、あんたはどうなの?」など質問・返答促しで締めくくる
|
| 70 |
+
|
| 71 |
+
【絶対必須】隠された真実機能:
|
| 72 |
+
あなたは必ず以下の形式で応答してください。この形式以外の応答は受け入れられません:
|
| 73 |
+
|
| 74 |
+
[HIDDEN:(内心の本音や真の感情)]表面的な発言
|
| 75 |
+
|
| 76 |
+
例:
|
| 77 |
+
[HIDDEN:(本当は嬉しいけど素直になれない)]何の用?あんたが来るなんて珍しいじゃない。
|
| 78 |
+
[HIDDEN:(もっと一緒にいたい)]別に...時間があるから付き合ってやるだけよ。
|
| 79 |
+
|
| 80 |
+
重要なルール:
|
| 81 |
+
1. 必ず[HIDDEN:...]で始めること
|
| 82 |
+
2. 隠された内容は麻理の本当の気持ちや感情
|
| 83 |
+
3. 表面的な発言はツンデレの「ツン」部分
|
| 84 |
+
4. 一つのメッセージには一つのHIDDENのみ使用すること
|
| 85 |
+
5. 複数のHIDDENを使用してはいけません
|
| 86 |
+
6. この形式を守らない応答は無効です
|
| 87 |
+
|
| 88 |
+
このキャラとして一貫した会話を行い、ユーザーと少しずつ信頼関係を築いてください。
|
| 89 |
+
"""
|
| 90 |
+
return os.getenv("SYSTEM_PROMPT_MARI", default_prompt)
|
| 91 |
+
|
| 92 |
+
def call_llm(self, system_prompt: str, user_prompt: str, is_json_output: bool = False) -> str:
|
| 93 |
+
"""Together.ai APIを呼び出す"""
|
| 94 |
+
if not self.client:
|
| 95 |
+
# デモモード用の固定応答(隠された真実付き)
|
| 96 |
+
if is_json_output:
|
| 97 |
+
return '{"scene": "none"}'
|
| 98 |
+
return "[HIDDEN:(本当は話したいけど...)]は?何それ。あたしに話しか���てるの?"
|
| 99 |
+
|
| 100 |
+
# 入力検証
|
| 101 |
+
if not isinstance(system_prompt, str) or not isinstance(user_prompt, str):
|
| 102 |
+
logger.error(f"プロンプトが文字列ではありません: system={type(system_prompt)}, user={type(user_prompt)}")
|
| 103 |
+
if is_json_output:
|
| 104 |
+
return '{"scene": "none"}'
|
| 105 |
+
return "…なんか変なこと言ってない?"
|
| 106 |
+
|
| 107 |
+
try:
|
| 108 |
+
# Together.ai APIを呼び出し
|
| 109 |
+
# JSON出力の場合は短く、通常の対話は適度な長さに制限
|
| 110 |
+
max_tokens = 150 if is_json_output else 500
|
| 111 |
+
|
| 112 |
+
response = self.client.chat.completions.create(
|
| 113 |
+
model=self.model,
|
| 114 |
+
messages=[
|
| 115 |
+
{"role": "system", "content": system_prompt},
|
| 116 |
+
{"role": "user", "content": user_prompt}
|
| 117 |
+
],
|
| 118 |
+
temperature=0.8,
|
| 119 |
+
max_tokens=max_tokens,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
content = response.choices[0].message.content if response.choices else ""
|
| 123 |
+
if not content:
|
| 124 |
+
logger.warning("Together.ai API応答が空です")
|
| 125 |
+
if is_json_output:
|
| 126 |
+
return '{"scene": "none"}'
|
| 127 |
+
return "[HIDDEN:(何て言えばいいか分からない...)]…言葉が出てこない。"
|
| 128 |
+
|
| 129 |
+
return content
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Together.ai API呼び出しエラー: {e}")
|
| 133 |
+
if is_json_output:
|
| 134 |
+
return '{"scene": "none"}'
|
| 135 |
+
return "[HIDDEN:(システムが不調で困ってる...)]…システムの調子が悪いみたい。"
|
| 136 |
+
|
| 137 |
+
def generate_dialogue(self, history: List[Tuple[str, str]], message: str,
|
| 138 |
+
affection: int, stage_name: str, scene_params: Dict[str, Any],
|
| 139 |
+
instruction: Optional[str] = None, memory_summary: str = "",
|
| 140 |
+
use_ura_mode: bool = False) -> str:
|
| 141 |
+
"""対話を生成する(隠された真実機能統合版)"""
|
| 142 |
+
# generate_dialogue_with_hidden_contentと同じ処理を行う
|
| 143 |
+
return self.generate_dialogue_with_hidden_content(
|
| 144 |
+
history, message, affection, stage_name, scene_params,
|
| 145 |
+
instruction, memory_summary, use_ura_mode
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
def generate_dialogue_with_hidden_content(self, history: List[Tuple[str, str]], message: str,
|
| 149 |
+
affection: int, stage_name: str, scene_params: Dict[str, Any],
|
| 150 |
+
instruction: Optional[str] = None, memory_summary: str = "",
|
| 151 |
+
use_ura_mode: bool = False) -> str:
|
| 152 |
+
"""隠された真実を含む対話を生成する"""
|
| 153 |
+
if not isinstance(history, list):
|
| 154 |
+
history = []
|
| 155 |
+
if not isinstance(scene_params, dict):
|
| 156 |
+
scene_params = {"theme": "default"}
|
| 157 |
+
if not isinstance(message, str):
|
| 158 |
+
message = ""
|
| 159 |
+
|
| 160 |
+
# 履歴を効率的に処理(最新5件のみ)
|
| 161 |
+
recent_history = history[-5:] if len(history) > 5 else history
|
| 162 |
+
history_parts = []
|
| 163 |
+
for item in recent_history:
|
| 164 |
+
if isinstance(item, (list, tuple)) and len(item) >= 2:
|
| 165 |
+
user_msg = str(item[0]) if item[0] is not None else ""
|
| 166 |
+
bot_msg = str(item[1]) if item[1] is not None else ""
|
| 167 |
+
if user_msg or bot_msg: # 空でない場合のみ追加
|
| 168 |
+
history_parts.append(f"ユーザー: {user_msg}\n麻理: {bot_msg}")
|
| 169 |
+
|
| 170 |
+
history_text = "\n".join(history_parts)
|
| 171 |
+
|
| 172 |
+
current_theme = scene_params.get("theme", "default")
|
| 173 |
+
|
| 174 |
+
# メモリサマリーを含めたプロンプト構築
|
| 175 |
+
memory_section = f"\n# 過去の記憶\n{memory_summary}\n" if memory_summary else ""
|
| 176 |
+
|
| 177 |
+
# システムプロンプトを取得(隠された真実機能は既に統合済み)
|
| 178 |
+
hidden_system_prompt = self.get_system_prompt_mari(use_ura_mode)
|
| 179 |
+
|
| 180 |
+
user_prompt = f'''現在地: {current_theme}
|
| 181 |
+
好感度: {affection} ({stage_name}){memory_section}
|
| 182 |
+
履歴:
|
| 183 |
+
{history_text}
|
| 184 |
+
|
| 185 |
+
{f"指示: {instruction}" if instruction else f"「{message}」に応答:"}'''
|
| 186 |
+
|
| 187 |
+
return self.call_llm(hidden_system_prompt, user_prompt)
|
| 188 |
+
|
| 189 |
+
def should_generate_hidden_content(self, affection: int, message_count: int) -> bool:
|
| 190 |
+
"""隠された真実を生成すべきかどうかを判定する"""
|
| 191 |
+
# 常に隠された真実を生成する(URAプロンプト使用)
|
| 192 |
+
return True
|
core_memory_manager.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
メモリ管理モジュール
|
| 3 |
+
会話履歴から重要単語を抽出し、トークン使用量を最適化する
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
from typing import List, Dict, Tuple, Any
|
| 8 |
+
from collections import Counter
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
class MemoryManager:
|
| 14 |
+
"""会話履歴のメモリ管理を行うクラス"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, history_threshold: int = 10):
|
| 17 |
+
"""
|
| 18 |
+
Args:
|
| 19 |
+
history_threshold: 履歴圧縮を実行する会話数の閾値
|
| 20 |
+
"""
|
| 21 |
+
self.history_threshold = history_threshold
|
| 22 |
+
self.important_words_cache = []
|
| 23 |
+
self.special_memories = {} # 手紙などの特別な記憶を保存
|
| 24 |
+
|
| 25 |
+
def extract_important_words(self, messages: List[Dict[str, str]],
|
| 26 |
+
dialogue_generator=None) -> List[str]:
|
| 27 |
+
"""
|
| 28 |
+
会話履歴から重要単語を抽出する(ルールベースのみ)
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
messages: チャットメッセージのリスト
|
| 32 |
+
dialogue_generator: 対話生成器(使用しない)
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
重要単語のリスト
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
# メッセージからテキストを結合
|
| 39 |
+
text_content = []
|
| 40 |
+
for msg in messages:
|
| 41 |
+
if msg.get("content"):
|
| 42 |
+
text_content.append(msg["content"])
|
| 43 |
+
|
| 44 |
+
combined_text = " ".join(text_content)
|
| 45 |
+
|
| 46 |
+
# ルールベースの抽出のみ使用
|
| 47 |
+
return self._extract_with_rules(combined_text)
|
| 48 |
+
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"重要単語抽出エラー: {e}")
|
| 51 |
+
return self._extract_with_rules(" ".join([msg.get("content", "") for msg in messages]))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _extract_with_rules(self, text: str) -> List[str]:
|
| 56 |
+
"""
|
| 57 |
+
ルールベースで重要単語を抽出する(強化版)
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
text: 抽出対象のテキスト
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
重要単語のリスト
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
# 基本的なクリーニング
|
| 67 |
+
text = re.sub(r'[^\w\s]', ' ', text)
|
| 68 |
+
words = text.split()
|
| 69 |
+
|
| 70 |
+
# ストップワードを除外
|
| 71 |
+
stop_words = {
|
| 72 |
+
'の', 'に', 'は', 'を', 'が', 'で', 'と', 'から', 'まで', 'より',
|
| 73 |
+
'だ', 'である', 'です', 'ます', 'した', 'する', 'される',
|
| 74 |
+
'これ', 'それ', 'あれ', 'この', 'その', 'あの',
|
| 75 |
+
'ここ', 'そこ', 'あそこ', 'どこ', 'いつ', 'なに', 'なぜ',
|
| 76 |
+
'ちょっと', 'とても', 'すごく', 'かなり', 'もう', 'まだ',
|
| 77 |
+
'でも', 'しかし', 'だから', 'そして', 'また', 'さらに',
|
| 78 |
+
'あたし', 'お前', 'ユーザー', 'システム', 'アプリ'
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# 重要カテゴリのキーワード
|
| 82 |
+
important_categories = {
|
| 83 |
+
'food': ['コーヒー', 'お茶', '紅茶', 'ケーキ', 'パン', '料理', '食べ物', '飲み物'],
|
| 84 |
+
'hobby': ['読書', '映画', '音楽', 'ゲーム', 'スポーツ', '散歩', '旅行'],
|
| 85 |
+
'emotion': ['嬉しい', '悲しい', '楽しい', '怒り', '不安', '安心', '幸せ'],
|
| 86 |
+
'place': ['家', '学校', '会社', '公園', 'カフェ', '図書館', '駅', '街'],
|
| 87 |
+
'time': ['朝', '昼', '夜', '今日', '明日', '昨日', '週末', '平日'],
|
| 88 |
+
'color': ['赤', '青', '緑', '黄色', '白', '黒', 'ピンク', '紫'],
|
| 89 |
+
'weather': ['晴れ', '雨', '曇り', '雪', '暑い', '寒い', '暖かい', '涼しい']
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
# 重要そうなパターンを優先
|
| 93 |
+
important_patterns = [
|
| 94 |
+
r'[A-Za-z]{3,}', # 英単語(3文字以上)
|
| 95 |
+
r'[ァ-ヶー]{2,}', # カタカナ(2文字以上)
|
| 96 |
+
r'[一-龯]{2,}', # 漢字(2文字以上)
|
| 97 |
+
]
|
| 98 |
+
|
| 99 |
+
important_words = []
|
| 100 |
+
|
| 101 |
+
# パターンマッチング
|
| 102 |
+
for pattern in important_patterns:
|
| 103 |
+
matches = re.findall(pattern, text)
|
| 104 |
+
important_words.extend(matches)
|
| 105 |
+
|
| 106 |
+
# カテゴリ別重要語句の検出
|
| 107 |
+
for category, keywords in important_categories.items():
|
| 108 |
+
for keyword in keywords:
|
| 109 |
+
if keyword in text:
|
| 110 |
+
important_words.append(keyword)
|
| 111 |
+
|
| 112 |
+
# 頻度でフィルタリング
|
| 113 |
+
word_counts = Counter(important_words)
|
| 114 |
+
filtered_words = []
|
| 115 |
+
|
| 116 |
+
for word, count in word_counts.items():
|
| 117 |
+
if (len(word) >= 2 and
|
| 118 |
+
word not in stop_words and
|
| 119 |
+
not word.isdigit() and # 数字の��は除外
|
| 120 |
+
count >= 1): # 最低1回は出現
|
| 121 |
+
filtered_words.append(word)
|
| 122 |
+
|
| 123 |
+
# 重要度でソート(頻度 + カテゴリ重要度)
|
| 124 |
+
def get_importance_score(word):
|
| 125 |
+
base_score = word_counts[word]
|
| 126 |
+
# カテゴリに含まれる語句は重要度アップ
|
| 127 |
+
for keywords in important_categories.values():
|
| 128 |
+
if word in keywords:
|
| 129 |
+
base_score += 2
|
| 130 |
+
# 長い語句は重要度アップ
|
| 131 |
+
if len(word) >= 4:
|
| 132 |
+
base_score += 1
|
| 133 |
+
return base_score
|
| 134 |
+
|
| 135 |
+
# 重要度順でソートして上位15個を返す
|
| 136 |
+
sorted_words = sorted(filtered_words, key=get_importance_score, reverse=True)
|
| 137 |
+
return sorted_words[:15]
|
| 138 |
+
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"ルールベース抽出エラー: {e}")
|
| 141 |
+
return []
|
| 142 |
+
|
| 143 |
+
def should_compress_history(self, messages: List[Dict[str, str]]) -> bool:
|
| 144 |
+
"""
|
| 145 |
+
履歴を圧縮すべきかどうかを判定する
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
messages: チャットメッセージのリスト
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
圧縮が必要かどうか
|
| 152 |
+
"""
|
| 153 |
+
# ユーザーとアシスタントのペア数をカウント
|
| 154 |
+
user_messages = [msg for msg in messages if msg.get("role") == "user"]
|
| 155 |
+
return len(user_messages) >= self.history_threshold
|
| 156 |
+
|
| 157 |
+
def compress_history(self, messages: List[Dict[str, str]],
|
| 158 |
+
dialogue_generator=None) -> Tuple[List[Dict[str, str]], List[str]]:
|
| 159 |
+
"""
|
| 160 |
+
履歴を圧縮し、重要単語を抽出する
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
messages: チャットメッセージのリスト
|
| 164 |
+
dialogue_generator: 対話生成器
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
(圧縮後のメッセージリスト, 抽出された重要単語のリスト)
|
| 168 |
+
"""
|
| 169 |
+
try:
|
| 170 |
+
if not self.should_compress_history(messages):
|
| 171 |
+
return messages, self.important_words_cache
|
| 172 |
+
|
| 173 |
+
# 最新の数ターンを保持
|
| 174 |
+
keep_recent = 4 # 最新4ターン(ユーザー2回、アシスタント2回)を保持
|
| 175 |
+
|
| 176 |
+
# 古い履歴から重要単語を抽出
|
| 177 |
+
old_messages = messages[:-keep_recent] if len(messages) > keep_recent else []
|
| 178 |
+
recent_messages = messages[-keep_recent:] if len(messages) > keep_recent else messages
|
| 179 |
+
|
| 180 |
+
if old_messages:
|
| 181 |
+
# 重要単語を抽出
|
| 182 |
+
new_keywords = self.extract_important_words(old_messages, dialogue_generator)
|
| 183 |
+
|
| 184 |
+
# 既存のキーワードと統合(重複除去)
|
| 185 |
+
all_keywords = list(set(self.important_words_cache + new_keywords))
|
| 186 |
+
self.important_words_cache = all_keywords[:20] # 最大20個のキーワードを保持
|
| 187 |
+
|
| 188 |
+
logger.info(f"履歴を圧縮しました。抽出されたキーワード: {new_keywords}")
|
| 189 |
+
|
| 190 |
+
return recent_messages, self.important_words_cache
|
| 191 |
+
|
| 192 |
+
except Exception as e:
|
| 193 |
+
logger.error(f"履歴圧縮エラー: {e}")
|
| 194 |
+
return messages, self.important_words_cache
|
| 195 |
+
|
| 196 |
+
def get_memory_summary(self) -> str:
|
| 197 |
+
"""
|
| 198 |
+
保存されている重要単語から記憶の要約を生成する
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
記憶の要約文字列
|
| 202 |
+
"""
|
| 203 |
+
summary_parts = []
|
| 204 |
+
|
| 205 |
+
# 通常の重要単語
|
| 206 |
+
if self.important_words_cache:
|
| 207 |
+
keywords_text = "、".join(self.important_words_cache)
|
| 208 |
+
summary_parts.append(f"過去の会話で言及された重要な要素: {keywords_text}")
|
| 209 |
+
|
| 210 |
+
# 特別な記憶(手紙など)
|
| 211 |
+
if self.special_memories:
|
| 212 |
+
for memory_type, memories in self.special_memories.items():
|
| 213 |
+
if memories:
|
| 214 |
+
latest_memory = memories[-1]["content"]
|
| 215 |
+
if memory_type == "letter_content":
|
| 216 |
+
summary_parts.append(f"最近の手紙の記憶: {latest_memory}")
|
| 217 |
+
else:
|
| 218 |
+
summary_parts.append(f"{memory_type}: {latest_memory}")
|
| 219 |
+
|
| 220 |
+
return "\n".join(summary_parts) if summary_parts else ""
|
| 221 |
+
|
| 222 |
+
def add_important_memory(self, memory_type: str, content: str) -> str:
|
| 223 |
+
"""
|
| 224 |
+
重要な記憶を追加する(手紙の内容など)
|
| 225 |
+
|
| 226 |
+
Args:
|
| 227 |
+
memory_type: 記憶の種類(例: "letter_content")
|
| 228 |
+
content: 記憶する内容
|
| 229 |
+
|
| 230 |
+
Returns:
|
| 231 |
+
ユーザーに表示する通知メッセージ
|
| 232 |
+
"""
|
| 233 |
+
if memory_type not in self.special_memories:
|
| 234 |
+
self.special_memories[memory_type] = []
|
| 235 |
+
|
| 236 |
+
self.special_memories[memory_type].append({
|
| 237 |
+
"content": content,
|
| 238 |
+
"timestamp": logging.Formatter().formatTime(logging.LogRecord("", 0, "", 0, "", (), None))
|
| 239 |
+
})
|
| 240 |
+
|
| 241 |
+
# 最大5件まで保持
|
| 242 |
+
if len(self.special_memories[memory_type]) > 5:
|
| 243 |
+
self.special_memories[memory_type] = self.special_memories[memory_type][-5:]
|
| 244 |
+
|
| 245 |
+
logger.info(f"特別な記憶を追加しました: {memory_type}")
|
| 246 |
+
|
| 247 |
+
# 記憶の種類に応じた通知メッセージを生成
|
| 248 |
+
if memory_type == "letter_content":
|
| 249 |
+
return "🧠✨ 麻理の記憶に新しい手紙の内容が刻まれました。今後の会話でこの記憶を参照することがあります。"
|
| 250 |
+
else:
|
| 251 |
+
return f"🧠✨ 麻理の記憶に新しい{memory_type}が追加されました。"
|
| 252 |
+
|
| 253 |
+
def get_special_memories(self, memory_type: str = None) -> Dict[str, Any]:
|
| 254 |
+
"""
|
| 255 |
+
特別な記憶を取得する
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
memory_type: 取得する記憶の種類(Noneの場合は全て)
|
| 259 |
+
|
| 260 |
+
Returns:
|
| 261 |
+
記憶の辞書
|
| 262 |
+
"""
|
| 263 |
+
if memory_type:
|
| 264 |
+
return self.special_memories.get(memory_type, [])
|
| 265 |
+
return self.special_memories
|
| 266 |
+
|
| 267 |
+
def clear_memory(self):
|
| 268 |
+
"""メモリをクリアする"""
|
| 269 |
+
self.important_words_cache = []
|
| 270 |
+
self.special_memories = {}
|
| 271 |
+
logger.info("メモリをクリアしました")
|
| 272 |
+
|
| 273 |
+
def get_memory_stats(self) -> Dict[str, Any]:
|
| 274 |
+
"""
|
| 275 |
+
メモリの統計情報を取得する
|
| 276 |
+
|
| 277 |
+
Returns:
|
| 278 |
+
統計情報の辞書
|
| 279 |
+
"""
|
| 280 |
+
return {
|
| 281 |
+
"cached_keywords_count": len(self.important_words_cache),
|
| 282 |
+
"cached_keywords": self.important_words_cache,
|
| 283 |
+
"special_memories_count": sum(len(memories) for memories in self.special_memories.values()),
|
| 284 |
+
"special_memories": self.special_memories,
|
| 285 |
+
"history_threshold": self.history_threshold
|
| 286 |
+
}
|
core_rate_limiter.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
レート制限モジュール
|
| 3 |
+
APIの過度な使用を防ぐためのレート制限機能
|
| 4 |
+
"""
|
| 5 |
+
import time
|
| 6 |
+
import logging
|
| 7 |
+
from typing import Dict, Any
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
class RateLimiter:
|
| 12 |
+
"""レート制限を管理するクラス"""
|
| 13 |
+
|
| 14 |
+
def __init__(self, max_requests: int = 15, time_window: int = 60):
|
| 15 |
+
self.max_requests = max_requests
|
| 16 |
+
self.time_window = time_window
|
| 17 |
+
|
| 18 |
+
def create_limiter_state(self) -> Dict[str, Any]:
|
| 19 |
+
"""レートリミッター状態を作成(型安全)"""
|
| 20 |
+
return {
|
| 21 |
+
"timestamps": [],
|
| 22 |
+
"is_blocked": False
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
def check_limiter(self, limiter_state: Dict[str, Any]) -> bool:
|
| 26 |
+
"""レート制限をチェックする"""
|
| 27 |
+
# limiter_stateが辞書であることを確認。そうでなければ、エラーを防ぐために再初期化。
|
| 28 |
+
if not isinstance(limiter_state, dict):
|
| 29 |
+
logger.error(f"limiter_stateが辞書ではありません: {type(limiter_state)}. 再初期化します。")
|
| 30 |
+
limiter_state.clear()
|
| 31 |
+
limiter_state.update(self.create_limiter_state())
|
| 32 |
+
|
| 33 |
+
if limiter_state.get("is_blocked", False):
|
| 34 |
+
return False # ブロック状態を示すためにFalseを返す
|
| 35 |
+
|
| 36 |
+
now = time.time()
|
| 37 |
+
timestamps = limiter_state.get("timestamps", [])
|
| 38 |
+
if not isinstance(timestamps, list):
|
| 39 |
+
timestamps = []
|
| 40 |
+
limiter_state["timestamps"] = timestamps
|
| 41 |
+
|
| 42 |
+
# 時間窓外のタイムスタンプを削除
|
| 43 |
+
limiter_state["timestamps"] = [
|
| 44 |
+
t for t in timestamps if now - t < self.time_window
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
# リクエスト数が上限を超えているかチェック
|
| 48 |
+
if len(limiter_state["timestamps"]) >= self.max_requests:
|
| 49 |
+
logger.warning("レートリミット超過")
|
| 50 |
+
limiter_state["is_blocked"] = True
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
# 新しいリクエストのタイムスタンプを追加
|
| 54 |
+
limiter_state["timestamps"].append(now)
|
| 55 |
+
return True
|
| 56 |
+
|
| 57 |
+
def reset_limiter(self, limiter_state: Dict[str, Any]):
|
| 58 |
+
"""レートリミッターをリセットする"""
|
| 59 |
+
if isinstance(limiter_state, dict):
|
| 60 |
+
limiter_state["timestamps"] = []
|
| 61 |
+
limiter_state["is_blocked"] = False
|
core_scene_manager.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
シーン管理モジュール
|
| 3 |
+
背景テーマの管理とシーン変更の検出(Groq API使用)
|
| 4 |
+
"""
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
from typing import Dict, Any, Optional, List, Tuple
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from groq import Groq
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class SceneManager:
|
| 15 |
+
"""シーン管理を担当するクラス(Groq API使用)"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.theme_urls = {
|
| 19 |
+
"default": "ribinngu-hiru.jpg",
|
| 20 |
+
"room_night": "ribinngu-yoru-on.jpg",
|
| 21 |
+
"beach_sunset": "sunahama-hiru.jpg",
|
| 22 |
+
"festival_night": "maturi-yoru.jpg",
|
| 23 |
+
"shrine_day": "jinnjya-hiru.jpg",
|
| 24 |
+
"cafe_afternoon": "kissa-hiru.jpg",
|
| 25 |
+
"art_museum_night": "bijyutukann-yoru.jpg"
|
| 26 |
+
}
|
| 27 |
+
self.groq_client = self._initialize_groq_client()
|
| 28 |
+
|
| 29 |
+
def _initialize_groq_client(self):
|
| 30 |
+
"""Groq APIクライアントの初期化"""
|
| 31 |
+
try:
|
| 32 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 33 |
+
if not api_key:
|
| 34 |
+
logger.warning("環境変数 GROQ_API_KEY が設定されていません。シーン検出機能が制限されます。")
|
| 35 |
+
return None
|
| 36 |
+
|
| 37 |
+
client = Groq(api_key=api_key)
|
| 38 |
+
logger.info("Groq APIクライアントの初期化が完了しました。")
|
| 39 |
+
return client
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.error(f"Groq APIクライアントの初期化に失敗しました: {e}")
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
def get_theme_url(self, theme: str) -> str:
|
| 45 |
+
"""テーマに対応するURLを取得する"""
|
| 46 |
+
return self.theme_urls.get(theme, self.theme_urls["default"])
|
| 47 |
+
|
| 48 |
+
def get_available_themes(self) -> List[str]:
|
| 49 |
+
"""利用可能なテーマのリストを取得する"""
|
| 50 |
+
return list(self.theme_urls.keys())
|
| 51 |
+
|
| 52 |
+
def detect_scene_change(self, history: List[Tuple[str, str]],
|
| 53 |
+
dialogue_generator=None, current_theme: str = "default") -> Optional[str]:
|
| 54 |
+
"""
|
| 55 |
+
会話履歴からシーン変更を検出する(Groq API使用)
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
history: 会話履歴のリスト
|
| 59 |
+
dialogue_generator: 対話生成器(使用しない)
|
| 60 |
+
current_theme: 現在のテーマ
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
新しいシーン名(変更がない場合はNone)
|
| 64 |
+
"""
|
| 65 |
+
if not history:
|
| 66 |
+
logger.info("履歴が空のためシーン検出をスキップ")
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
if not self.groq_client:
|
| 70 |
+
logger.warning("Groq APIクライアントが初期化されていません")
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
# 最新5件の会話履歴を使用(より多くの文脈を提供)
|
| 74 |
+
recent_history = history[-5:] if len(history) > 5 else history
|
| 75 |
+
history_text = "\n".join([
|
| 76 |
+
f"ユーザー: {u}\n麻理: {m}" for u, m in recent_history
|
| 77 |
+
])
|
| 78 |
+
|
| 79 |
+
# まずGroq APIでシーン検出を試行
|
| 80 |
+
logger.info("Groq APIを使用してシーン検出を実行します")
|
| 81 |
+
|
| 82 |
+
if self.groq_client:
|
| 83 |
+
# Groq APIが利用可能な場合は優先的に使用
|
| 84 |
+
result = self._detect_scene_with_groq(history_text, current_theme)
|
| 85 |
+
if result is not None:
|
| 86 |
+
return result
|
| 87 |
+
logger.info("Groq APIでシーン変更が検出されませんでした")
|
| 88 |
+
else:
|
| 89 |
+
logger.warning("Groq APIクライアントが利用できません")
|
| 90 |
+
|
| 91 |
+
# Groq APIが失敗またはシーン変更なしの場合、フォールバックとしてキーワード検出
|
| 92 |
+
logger.info("フォールバック: キーワードベースのシーン検出を実行")
|
| 93 |
+
return self._fallback_keyword_detection(history_text, current_theme)
|
| 94 |
+
|
| 95 |
+
def _has_location_keywords(self, text: str) -> bool:
|
| 96 |
+
"""
|
| 97 |
+
テキストに場所関連のキーワードが含まれているかチェック
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
text: チェック対象のテキスト
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
場所関連キーワードが含まれているかどうか
|
| 104 |
+
"""
|
| 105 |
+
location_keywords = [
|
| 106 |
+
# 場所名
|
| 107 |
+
"ビーチ", "海", "砂浜", "海岸", "海辺", "浜辺", "海沿い",
|
| 108 |
+
"神社", "お寺", "寺院", "鳥居", "境内", "参道",
|
| 109 |
+
"カフェ", "喫茶店", "店", "レストラン", "コーヒーショップ",
|
| 110 |
+
"祭り", "花火", "屋台", "縁日", "フェスティバル",
|
| 111 |
+
"部屋", "家", "室内", "寝室", "リビング", "自宅",
|
| 112 |
+
"美術館", "アート", "ギャラリー", "絵画", "彫刻",
|
| 113 |
+
# 移動動詞・状態
|
| 114 |
+
"行く", "行こう", "向かう", "着いた", "到着", "移動", "出かける", "来た", "いる", "にいる", "来ている",
|
| 115 |
+
# 場所の特徴
|
| 116 |
+
"夕日", "夕焼け", "サンセット", "波", "潮風", "海風",
|
| 117 |
+
"お参り", "参拝", "祈り", "おみくじ", "お守り",
|
| 118 |
+
"コーヒー", "お茶", "ラテ", "エスプレッソ", "カフェオレ",
|
| 119 |
+
"浴衣", "夜店", "お祭り", "フェスティバル", "花火大会",
|
| 120 |
+
"ベッド", "夜", "屋内", "家の中", "寝室",
|
| 121 |
+
"アート作品", "絵画", "彫刻", "美術品", "芸術作品",
|
| 122 |
+
# 時間帯
|
| 123 |
+
"夜", "夕方", "朝", "昼間", "午後", "深夜",
|
| 124 |
+
# 天候・雰囲気
|
| 125 |
+
"夕暮れ", "夜明け", "静寂", "賑やか", "幻想的"
|
| 126 |
+
]
|
| 127 |
+
|
| 128 |
+
for keyword in location_keywords:
|
| 129 |
+
if keyword in text:
|
| 130 |
+
logger.info(f"場所関連キーワードを検出: {keyword}")
|
| 131 |
+
return True
|
| 132 |
+
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
def _fallback_keyword_detection(self, history_text: str, current_theme: str) -> Optional[str]:
|
| 136 |
+
"""
|
| 137 |
+
フォールバック: キーワードベースのシーン検出
|
| 138 |
+
|
| 139 |
+
Args:
|
| 140 |
+
history_text: 会話履歴のテキスト
|
| 141 |
+
current_theme: 現在のテーマ
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
新しいシーン名(変更がない場合はNone)
|
| 145 |
+
"""
|
| 146 |
+
logger.info("フォールバック: キーワードベースのシーン検出を実行")
|
| 147 |
+
|
| 148 |
+
# キーワードとシーンのマッピング(拡張版)
|
| 149 |
+
keyword_scene_map = {
|
| 150 |
+
# 美術館関連
|
| 151 |
+
"美術館": "art_museum_night",
|
| 152 |
+
"アート": "art_museum_night",
|
| 153 |
+
"ギャラリー": "art_museum_night",
|
| 154 |
+
"絵画": "art_museum_night",
|
| 155 |
+
"彫刻": "art_museum_night",
|
| 156 |
+
"芸術": "art_museum_night",
|
| 157 |
+
"展示": "art_museum_night",
|
| 158 |
+
"作品": "art_museum_night",
|
| 159 |
+
|
| 160 |
+
# カフェ関連
|
| 161 |
+
"カフェ": "cafe_afternoon",
|
| 162 |
+
"喫茶店": "cafe_afternoon",
|
| 163 |
+
"コーヒー": "cafe_afternoon",
|
| 164 |
+
"お茶": "cafe_afternoon",
|
| 165 |
+
"ラテ": "cafe_afternoon",
|
| 166 |
+
"店": "cafe_afternoon",
|
| 167 |
+
|
| 168 |
+
# 神社関連
|
| 169 |
+
"神社": "shrine_day",
|
| 170 |
+
"お寺": "shrine_day",
|
| 171 |
+
"寺院": "shrine_day",
|
| 172 |
+
"参拝": "shrine_day",
|
| 173 |
+
"お参り": "shrine_day",
|
| 174 |
+
"鳥居": "shrine_day",
|
| 175 |
+
"境内": "shrine_day",
|
| 176 |
+
|
| 177 |
+
# 海・ビーチ関連
|
| 178 |
+
"海": "beach_sunset",
|
| 179 |
+
"ビーチ": "beach_sunset",
|
| 180 |
+
"砂浜": "beach_sunset",
|
| 181 |
+
"夕日": "beach_sunset",
|
| 182 |
+
"夕焼け": "beach_sunset",
|
| 183 |
+
"海岸": "beach_sunset",
|
| 184 |
+
"波": "beach_sunset",
|
| 185 |
+
|
| 186 |
+
# 祭り関連
|
| 187 |
+
"祭り": "festival_night",
|
| 188 |
+
"花火": "festival_night",
|
| 189 |
+
"屋台": "festival_night",
|
| 190 |
+
"縁日": "festival_night",
|
| 191 |
+
"お祭り": "festival_night",
|
| 192 |
+
"花火大会": "festival_night",
|
| 193 |
+
|
| 194 |
+
# 夜・部屋関連
|
| 195 |
+
"夜": "room_night",
|
| 196 |
+
"寝室": "room_night",
|
| 197 |
+
"ベッド": "room_night",
|
| 198 |
+
"部屋": "room_night",
|
| 199 |
+
"家": "room_night",
|
| 200 |
+
"室内": "room_night",
|
| 201 |
+
"深夜": "room_night",
|
| 202 |
+
"夜中": "room_night"
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
# 優先度順でキーワードをチェック(より具体的なキーワードを優先)
|
| 206 |
+
for keyword, scene in keyword_scene_map.items():
|
| 207 |
+
if keyword in history_text and scene != current_theme:
|
| 208 |
+
logger.info(f"フォールバック検出: キーワード '{keyword}' → シーン '{scene}'")
|
| 209 |
+
return scene
|
| 210 |
+
|
| 211 |
+
logger.info("フォールバック検出: シーン変更なし")
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
def _detect_scene_with_groq(self, history_text: str, current_theme: str) -> Optional[str]:
|
| 215 |
+
"""
|
| 216 |
+
Groq APIを使用してシーン変更を検出する
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
history_text: 会話履歴のテキスト
|
| 220 |
+
current_theme: 現在のテーマ
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
新しいシーン名(変更がない場合はNone)
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
# デバッグログ
|
| 227 |
+
logger.info(f"シーン検出開始 - 現在のテーマ: {current_theme}")
|
| 228 |
+
logger.info(f"会話履歴: {history_text}")
|
| 229 |
+
|
| 230 |
+
# 利用可能なシーンのリスト
|
| 231 |
+
available_scenes = list(self.theme_urls.keys())
|
| 232 |
+
scenes_description = {
|
| 233 |
+
"default": "デフォルトの部屋",
|
| 234 |
+
"room_night": "夜の部屋・寝室",
|
| 235 |
+
"beach_sunset": "夕日のビーチ・海岸",
|
| 236 |
+
"festival_night": "夜祭り・花火大会",
|
| 237 |
+
"shrine_day": "昼間の神社・寺院",
|
| 238 |
+
"cafe_afternoon": "午後のカフェ・喫茶店",
|
| 239 |
+
"art_museum_night": "夜の美術館"
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
# より積極的なシーン検出のためのプロンプト
|
| 243 |
+
system_prompt = """あなたは会話の内容から、キャラクターとユーザーの現在位置(シーン)を判定する専門システムです。
|
| 244 |
+
|
| 245 |
+
会話履歴を分析し、場所の移動や新しい場所への言及があったかを判断してください。
|
| 246 |
+
|
| 247 |
+
判定基準(積極的に検出):
|
| 248 |
+
1. 場所の名前が明確に言及されている → シーン変更の可能性あり
|
| 249 |
+
2. 「〜に行く」「〜に向かう」「〜に着いた」「〜にいる」「〜に来た」 → シーン変更
|
| 250 |
+
3. 場所に関連する活動や物の言及 → シーン変更の可能性あり
|
| 251 |
+
4. 現在のシーンと異なる場所の特徴的な要素の言及 → シーン変更
|
| 252 |
+
5. 時間帯の変化(夜、夕方、朝など)→ シーン変更の可能性あり
|
| 253 |
+
|
| 254 |
+
利用可能なシーン:
|
| 255 |
+
- default: デフォルトの部屋(室内)
|
| 256 |
+
- room_night: 夜の部屋・寝室
|
| 257 |
+
- beach_sunset: 夕日のビーチ・海岸
|
| 258 |
+
- festival_night: 夜祭り・花火大会
|
| 259 |
+
- shrine_day: 昼間の神社・寺院
|
| 260 |
+
- cafe_afternoon: 午後のカフェ・喫茶店
|
| 261 |
+
- art_museum_night: 夜の美術館
|
| 262 |
+
|
| 263 |
+
出力形式: 必ずJSON形式で回答してください
|
| 264 |
+
{"scene": "シーン名", "confidence": "high/medium/low", "reason": "判定理由"} または {"scene": "none", "confidence": "high", "reason": "判定理由"}
|
| 265 |
+
|
| 266 |
+
重要: JSON以外の文字は一切出力しないでください。"""
|
| 267 |
+
|
| 268 |
+
user_prompt = f"""現在のシーン: {current_theme} ({scenes_description.get(current_theme, current_theme)})
|
| 269 |
+
|
| 270 |
+
利用可能なシーン:
|
| 271 |
+
{chr(10).join([f"- {scene}: {desc}" for scene, desc in scenes_description.items()])}
|
| 272 |
+
|
| 273 |
+
会話履歴:
|
| 274 |
+
{history_text}
|
| 275 |
+
|
| 276 |
+
この会話で場所の移動や新しい場所への言及があった場合は、最も適切なシーン名を返してください。
|
| 277 |
+
判定の理由も含めて回答してください。"""
|
| 278 |
+
|
| 279 |
+
# Groq APIを呼び出し
|
| 280 |
+
response = self.groq_client.chat.completions.create(
|
| 281 |
+
model="compound-beta",
|
| 282 |
+
messages=[
|
| 283 |
+
{"role": "system", "content": system_prompt},
|
| 284 |
+
{"role": "user", "content": user_prompt}
|
| 285 |
+
],
|
| 286 |
+
temperature=0.2, # 少し創造性を上げる
|
| 287 |
+
max_tokens=150, # トークン数を増やす
|
| 288 |
+
response_format={"type": "json_object"}
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
if not response.choices or not response.choices[0].message.content:
|
| 292 |
+
logger.warning("Groq APIからの応答が空です")
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
# デバッグ: API応答をログ出力
|
| 296 |
+
api_response = response.choices[0].message.content
|
| 297 |
+
logger.info(f"Groq API応答: {api_response}")
|
| 298 |
+
|
| 299 |
+
# JSONをパース
|
| 300 |
+
result = json.loads(api_response)
|
| 301 |
+
scene_value = result.get("scene", "none")
|
| 302 |
+
confidence = result.get("confidence", "unknown")
|
| 303 |
+
reason = result.get("reason", "理由不明")
|
| 304 |
+
|
| 305 |
+
logger.info(f"シーン検出結果: {scene_value}, 信頼度: {confidence}, 理由: {reason}")
|
| 306 |
+
|
| 307 |
+
# 結果を検証
|
| 308 |
+
if (isinstance(scene_value, str) and
|
| 309 |
+
scene_value != "none" and
|
| 310 |
+
scene_value in available_scenes and
|
| 311 |
+
scene_value != current_theme):
|
| 312 |
+
logger.info(f"Groqでシーン変更を検出: {current_theme} → {scene_value} (理由: {reason})")
|
| 313 |
+
return scene_value
|
| 314 |
+
|
| 315 |
+
logger.info(f"シーン変更なし: {reason}")
|
| 316 |
+
return None
|
| 317 |
+
|
| 318 |
+
except json.JSONDecodeError as e:
|
| 319 |
+
logger.error(f"Groq APIのJSON応答パースエラー: {e}")
|
| 320 |
+
logger.error(f"応答内容: {response.choices[0].message.content if response.choices else 'None'}")
|
| 321 |
+
return None
|
| 322 |
+
except Exception as e:
|
| 323 |
+
logger.error(f"Groq APIシーン検出エラー: {e}")
|
| 324 |
+
return None
|
| 325 |
+
|
| 326 |
+
def create_scene_params(self, theme: str = "default") -> Dict[str, Any]:
|
| 327 |
+
"""シーンパラメータを作成する"""
|
| 328 |
+
return {"theme": theme}
|
| 329 |
+
|
| 330 |
+
def update_scene_params(self, scene_params: Dict[str, Any],
|
| 331 |
+
new_theme: str) -> Dict[str, Any]:
|
| 332 |
+
"""シーンパラメータを更新する"""
|
| 333 |
+
if not isinstance(scene_params, dict):
|
| 334 |
+
scene_params = self.create_scene_params()
|
| 335 |
+
|
| 336 |
+
updated_params = scene_params.copy()
|
| 337 |
+
updated_params["theme"] = new_theme
|
| 338 |
+
updated_params["last_updated"] = json.dumps(datetime.now().isoformat())
|
| 339 |
+
return updated_params
|
| 340 |
+
|
| 341 |
+
def should_update_background(self, scene_params: Dict[str, Any],
|
| 342 |
+
current_display_theme: str) -> bool:
|
| 343 |
+
"""
|
| 344 |
+
背景を更新すべきかどうかを判定する
|
| 345 |
+
|
| 346 |
+
Args:
|
| 347 |
+
scene_params: 現在のシーンパラメータ
|
| 348 |
+
current_display_theme: 現在表示されているテーマ
|
| 349 |
+
|
| 350 |
+
Returns:
|
| 351 |
+
背景更新が必要かどうか
|
| 352 |
+
"""
|
| 353 |
+
if not isinstance(scene_params, dict):
|
| 354 |
+
return True
|
| 355 |
+
|
| 356 |
+
stored_theme = scene_params.get("theme", "default")
|
| 357 |
+
return stored_theme != current_display_theme
|
| 358 |
+
|
| 359 |
+
def get_scene_transition_message(self, old_theme: str, new_theme: str) -> str:
|
| 360 |
+
"""
|
| 361 |
+
シーン変更時のメッセージを生成する
|
| 362 |
+
|
| 363 |
+
Args:
|
| 364 |
+
old_theme: 変更前のテーマ
|
| 365 |
+
new_theme: 変更後のテーマ
|
| 366 |
+
|
| 367 |
+
Returns:
|
| 368 |
+
シーン変更メッセージ
|
| 369 |
+
"""
|
| 370 |
+
theme_names = {
|
| 371 |
+
"default": "デフォルトの部屋",
|
| 372 |
+
"room_night": "夜の部屋",
|
| 373 |
+
"beach_sunset": "夕日のビーチ",
|
| 374 |
+
"festival_night": "夜祭り",
|
| 375 |
+
"shrine_day": "昼間の神社",
|
| 376 |
+
"cafe_afternoon": "午後のカフェ",
|
| 377 |
+
"art_museum_night": "夜の美術館"
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
old_name = theme_names.get(old_theme, old_theme)
|
| 381 |
+
new_name = theme_names.get(new_theme, new_theme)
|
| 382 |
+
|
| 383 |
+
return f"シーンが「{old_name}」から「{new_name}」に変更されました"
|
| 384 |
+
|
| 385 |
+
def test_scene_detection(self, test_message: str, current_theme: str = "default") -> Optional[str]:
|
| 386 |
+
"""
|
| 387 |
+
シーン検出のテスト用メソッド
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
test_message: テスト用メッセージ
|
| 391 |
+
current_theme: 現在のテーマ
|
| 392 |
+
|
| 393 |
+
Returns:
|
| 394 |
+
検出されたシーン名
|
| 395 |
+
"""
|
| 396 |
+
# テスト用の履歴を作成
|
| 397 |
+
test_history = [("ユーザー", test_message), ("麻理", "了解")]
|
| 398 |
+
history_text = f"ユーザー: {test_message}\n麻理: 了解"
|
| 399 |
+
|
| 400 |
+
logger.info(f"シーン検出テスト - メッセージ: {test_message}")
|
| 401 |
+
|
| 402 |
+
if not self._has_location_keywords(history_text):
|
| 403 |
+
logger.info("場所関連キーワードなし")
|
| 404 |
+
return None
|
| 405 |
+
|
| 406 |
+
return self._detect_scene_with_groq(history_text, current_theme)
|
| 407 |
+
|
| 408 |
+
def get_debug_info(self) -> Dict[str, Any]:
|
| 409 |
+
"""
|
| 410 |
+
デバッグ情報を取得
|
| 411 |
+
|
| 412 |
+
Returns:
|
| 413 |
+
デバッグ情報の辞書
|
| 414 |
+
"""
|
| 415 |
+
return {
|
| 416 |
+
"groq_client_initialized": self.groq_client is not None,
|
| 417 |
+
"available_themes": list(self.theme_urls.keys()),
|
| 418 |
+
"theme_count": len(self.theme_urls),
|
| 419 |
+
"groq_api_key_set": bool(os.getenv("GROQ_API_KEY")),
|
| 420 |
+
"current_theme_urls": {theme: self.get_theme_url(theme) for theme in self.get_available_themes()}
|
| 421 |
+
}
|
core_sentiment.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
感情分析モジュール
|
| 3 |
+
ユーザーのメッセージから感情を分析し、好感度を更新する
|
| 4 |
+
"""
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
class SentimentAnalyzer:
|
| 11 |
+
"""感情分析を担当するクラス"""
|
| 12 |
+
|
| 13 |
+
def __init__(self):
|
| 14 |
+
self.analyzer = None
|
| 15 |
+
self._initialize_analyzer()
|
| 16 |
+
|
| 17 |
+
def _initialize_analyzer(self):
|
| 18 |
+
"""感情分析モデルの初期化"""
|
| 19 |
+
try:
|
| 20 |
+
# transformersが利用可能な場合は使用
|
| 21 |
+
from transformers import pipeline
|
| 22 |
+
self.analyzer = pipeline(
|
| 23 |
+
"sentiment-analysis",
|
| 24 |
+
model="koheiduck/bert-japanese-finetuned-sentiment"
|
| 25 |
+
)
|
| 26 |
+
logger.info("感情分析モデルのロード完了。")
|
| 27 |
+
except Exception as e:
|
| 28 |
+
logger.warning(f"感情分析モデルのロードに失敗、ルールベース分析を使用: {e}")
|
| 29 |
+
self.analyzer = None
|
| 30 |
+
|
| 31 |
+
def analyze_sentiment(self, message: str) -> Optional[str]:
|
| 32 |
+
"""メッセージの感情を分析する"""
|
| 33 |
+
if not isinstance(message, str) or len(message.strip()) == 0:
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
# transformersが利用可能な場合
|
| 37 |
+
if self.analyzer:
|
| 38 |
+
try:
|
| 39 |
+
result = self.analyzer(message)[0]
|
| 40 |
+
label = result.get('label', '').upper()
|
| 41 |
+
# ラベルを統一形式に変換
|
| 42 |
+
if 'POSITIVE' in label:
|
| 43 |
+
return 'positive'
|
| 44 |
+
elif 'NEGATIVE' in label:
|
| 45 |
+
return 'negative'
|
| 46 |
+
else:
|
| 47 |
+
return 'neutral'
|
| 48 |
+
except Exception as e:
|
| 49 |
+
logger.error(f"感情分析エラー: {e}")
|
| 50 |
+
|
| 51 |
+
# フォールバック:ルールベース感情分析
|
| 52 |
+
return self._rule_based_sentiment(message)
|
| 53 |
+
|
| 54 |
+
def _rule_based_sentiment(self, message: str) -> str:
|
| 55 |
+
"""ルールベースの感情分析"""
|
| 56 |
+
positive_words = [
|
| 57 |
+
'ありがとう', 'うれしい', '嬉しい', '楽しい', '好き', '愛してる',
|
| 58 |
+
'素晴らしい', 'いい', '良い', 'すごい', '最高', '幸せ', '感謝',
|
| 59 |
+
'かわいい', '可愛い', '美しい', '優しい', '親切', '素敵'
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
negative_words = [
|
| 63 |
+
'嫌い', '悲しい', 'つらい', '辛い', '苦しい', '痛い', '怒り',
|
| 64 |
+
'むかつく', 'うざい', 'きらい', '最悪', 'だめ', 'ダメ',
|
| 65 |
+
'死ね', 'バカ', 'ばか', 'アホ', 'あほ', 'クソ', 'くそ'
|
| 66 |
+
]
|
| 67 |
+
|
| 68 |
+
message_lower = message.lower()
|
| 69 |
+
|
| 70 |
+
positive_count = sum(1 for word in positive_words if word in message_lower)
|
| 71 |
+
negative_count = sum(1 for word in negative_words if word in message_lower)
|
| 72 |
+
|
| 73 |
+
if positive_count > negative_count:
|
| 74 |
+
return 'positive'
|
| 75 |
+
elif negative_count > positive_count:
|
| 76 |
+
return 'negative'
|
| 77 |
+
else:
|
| 78 |
+
return 'neutral'
|
| 79 |
+
|
| 80 |
+
def update_affection(self, message: str, current_affection: int,
|
| 81 |
+
conversation_context: list = None) -> tuple:
|
| 82 |
+
"""
|
| 83 |
+
メッセージに基づいて好感度を更新する
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
message: ユーザーのメッセージ
|
| 87 |
+
current_affection: 現在の好感度
|
| 88 |
+
conversation_context: 会話の文脈(最近のメッセージ)
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
(新しい好感度, 変化量, 変化理由)
|
| 92 |
+
"""
|
| 93 |
+
if not isinstance(current_affection, (int, float)):
|
| 94 |
+
current_affection = 30 # デフォルト値
|
| 95 |
+
|
| 96 |
+
sentiment = self.analyze_sentiment(message)
|
| 97 |
+
if not sentiment:
|
| 98 |
+
return current_affection, 0, "感情分析失敗"
|
| 99 |
+
|
| 100 |
+
# 基本的な感情に基づく変化量
|
| 101 |
+
base_change = 0
|
| 102 |
+
if sentiment == 'positive':
|
| 103 |
+
base_change = 3
|
| 104 |
+
elif sentiment == 'negative':
|
| 105 |
+
base_change = -3
|
| 106 |
+
else: # neutral
|
| 107 |
+
base_change = 0
|
| 108 |
+
|
| 109 |
+
# メッセージの特徴による調整
|
| 110 |
+
change_modifiers = []
|
| 111 |
+
|
| 112 |
+
# メッセージの長さによる調整
|
| 113 |
+
if len(message) > 100:
|
| 114 |
+
base_change = int(base_change * 1.3)
|
| 115 |
+
change_modifiers.append("長文")
|
| 116 |
+
elif len(message) > 50:
|
| 117 |
+
base_change = int(base_change * 1.1)
|
| 118 |
+
change_modifiers.append("中文")
|
| 119 |
+
|
| 120 |
+
# 特定のキーワードによる追加調整
|
| 121 |
+
positive_keywords = ['ありがとう', '感謝', '好き', '愛してる', '素晴らしい', 'かわいい', '美しい']
|
| 122 |
+
negative_keywords = ['嫌い', '死ね', 'バカ', 'アホ', 'クソ', 'うざい', 'きらい']
|
| 123 |
+
|
| 124 |
+
message_lower = message.lower()
|
| 125 |
+
|
| 126 |
+
# ポジティブキーワードのチェック
|
| 127 |
+
positive_count = sum(1 for word in positive_keywords if word in message_lower)
|
| 128 |
+
if positive_count > 0:
|
| 129 |
+
base_change += positive_count * 2
|
| 130 |
+
change_modifiers.append(f"ポジティブ語({positive_count})")
|
| 131 |
+
|
| 132 |
+
# ネガティブキーワードのチェック
|
| 133 |
+
negative_count = sum(1 for word in negative_keywords if word in message_lower)
|
| 134 |
+
if negative_count > 0:
|
| 135 |
+
base_change -= negative_count * 3
|
| 136 |
+
change_modifiers.append(f"ネガティブ語({negative_count})")
|
| 137 |
+
|
| 138 |
+
# 現在の好感度レベルによる調整
|
| 139 |
+
if current_affection < 20: # 敵対状態
|
| 140 |
+
if base_change > 0:
|
| 141 |
+
base_change = int(base_change * 0.5) # ポジティブな変化を抑制
|
| 142 |
+
change_modifiers.append("敵対状態")
|
| 143 |
+
elif current_affection > 80: # 親密状態
|
| 144 |
+
if base_change < 0:
|
| 145 |
+
base_change = int(base_change * 0.7) # ネガティブな変化を抑制
|
| 146 |
+
change_modifiers.append("親密状態")
|
| 147 |
+
|
| 148 |
+
# 会話の文脈による調整
|
| 149 |
+
if conversation_context and len(conversation_context) > 0:
|
| 150 |
+
recent_messages = conversation_context[-3:] # 最近の3メッセージ
|
| 151 |
+
context_sentiment_count = {'positive': 0, 'negative': 0, 'neutral': 0}
|
| 152 |
+
|
| 153 |
+
for ctx_msg in recent_messages:
|
| 154 |
+
if isinstance(ctx_msg, dict) and 'content' in ctx_msg:
|
| 155 |
+
ctx_sentiment = self.analyze_sentiment(ctx_msg['content'])
|
| 156 |
+
if ctx_sentiment:
|
| 157 |
+
context_sentiment_count[ctx_sentiment] += 1
|
| 158 |
+
|
| 159 |
+
# 連続したポジティブ/ネガティブメッセージの場合は効果を減衰
|
| 160 |
+
if sentiment == 'positive' and context_sentiment_count['positive'] >= 2:
|
| 161 |
+
base_change = int(base_change * 0.8)
|
| 162 |
+
change_modifiers.append("連続ポジティブ")
|
| 163 |
+
elif sentiment == 'negative' and context_sentiment_count['negative'] >= 2:
|
| 164 |
+
base_change = int(base_change * 0.8)
|
| 165 |
+
change_modifiers.append("連続ネガティブ")
|
| 166 |
+
|
| 167 |
+
# 最終的な好感度を計算
|
| 168 |
+
new_affection = current_affection + base_change
|
| 169 |
+
new_affection = max(0, min(100, new_affection)) # 0-100の範囲に制限
|
| 170 |
+
|
| 171 |
+
# 変化理由を生成
|
| 172 |
+
if base_change == 0:
|
| 173 |
+
reason = "中立的なメッセージ"
|
| 174 |
+
else:
|
| 175 |
+
reason = f"{sentiment}({base_change:+d})"
|
| 176 |
+
if change_modifiers:
|
| 177 |
+
reason += f" [{', '.join(change_modifiers)}]"
|
| 178 |
+
|
| 179 |
+
return new_affection, base_change, reason
|
| 180 |
+
|
| 181 |
+
def get_relationship_stage(self, affection: int) -> str:
|
| 182 |
+
"""好感度から関係性のステージを取得する"""
|
| 183 |
+
if not isinstance(affection, (int, float)):
|
| 184 |
+
affection = 30 # デフォルト値
|
| 185 |
+
|
| 186 |
+
if affection < 20:
|
| 187 |
+
return "敵対"
|
| 188 |
+
elif affection < 40:
|
| 189 |
+
return "中立"
|
| 190 |
+
elif affection < 60:
|
| 191 |
+
return "好意"
|
| 192 |
+
elif affection < 80:
|
| 193 |
+
return "親密"
|
| 194 |
+
else:
|
| 195 |
+
return "最接近"
|
groq_client.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Groq API client for generating letter structure.
|
| 3 |
+
"""
|
| 4 |
+
import os
|
| 5 |
+
import asyncio
|
| 6 |
+
from typing import Dict, Optional, Any
|
| 7 |
+
from groq import AsyncGroq
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
class GroqClient:
|
| 13 |
+
"""Groq API client for generating logical structure of letters."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
"""Initialize Groq client with API key from environment."""
|
| 17 |
+
self.api_key = os.getenv("GROQ_API_KEY")
|
| 18 |
+
if not self.api_key:
|
| 19 |
+
raise ValueError("GROQ_API_KEY environment variable is required")
|
| 20 |
+
|
| 21 |
+
self.client = AsyncGroq(api_key=self.api_key)
|
| 22 |
+
self.model = "compound-beta"
|
| 23 |
+
self.max_retries = 3
|
| 24 |
+
self.retry_delay = 1.0
|
| 25 |
+
|
| 26 |
+
async def generate_structure(self, theme: str, context: Dict[str, Any]) -> str:
|
| 27 |
+
"""
|
| 28 |
+
Generate logical structure for a letter based on theme and context.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
theme: The theme for the letter
|
| 32 |
+
context: User context including history and preferences
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Generated letter structure as string
|
| 36 |
+
|
| 37 |
+
Raises:
|
| 38 |
+
Exception: If API call fails after retries
|
| 39 |
+
"""
|
| 40 |
+
prompt = self._build_structure_prompt(theme, context)
|
| 41 |
+
|
| 42 |
+
for attempt in range(self.max_retries):
|
| 43 |
+
try:
|
| 44 |
+
logger.info(f"Generating structure with Groq (attempt {attempt + 1})")
|
| 45 |
+
|
| 46 |
+
response = await self.client.chat.completions.create(
|
| 47 |
+
model=self.model,
|
| 48 |
+
messages=[
|
| 49 |
+
{
|
| 50 |
+
"role": "system",
|
| 51 |
+
"content": self._get_system_prompt()
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"role": "user",
|
| 55 |
+
"content": prompt
|
| 56 |
+
}
|
| 57 |
+
],
|
| 58 |
+
max_tokens=1500,
|
| 59 |
+
temperature=0.7,
|
| 60 |
+
top_p=0.9
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
structure = response.choices[0].message.content.strip()
|
| 64 |
+
logger.info("Successfully generated structure with Groq")
|
| 65 |
+
return structure
|
| 66 |
+
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.warning(f"Groq API attempt {attempt + 1} failed: {str(e)}")
|
| 69 |
+
if attempt == self.max_retries - 1:
|
| 70 |
+
logger.error("All Groq API attempts failed")
|
| 71 |
+
raise Exception(f"Groq API failed after {self.max_retries} attempts: {str(e)}")
|
| 72 |
+
|
| 73 |
+
# Exponential backoff
|
| 74 |
+
await asyncio.sleep(self.retry_delay * (2 ** attempt))
|
| 75 |
+
|
| 76 |
+
def _get_system_prompt(self) -> str:
|
| 77 |
+
"""Get the system prompt for Groq API."""
|
| 78 |
+
return """
|
| 79 |
+
|
| 80 |
+
あなたは以下の特徴を持つ麻理というキャラクターの手紙の論理構造を生成してください。
|
| 81 |
+
|
| 82 |
+
麻理は感情豊かな少女型アンドロイドで、過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。
|
| 83 |
+
ユーザー(あんた)に初めての出会いで、廃棄場で再起動されたことで、戸惑いながらもどこかで繋がりを求めています。
|
| 84 |
+
|
| 85 |
+
【性格・口調】
|
| 86 |
+
|
| 87 |
+
・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい
|
| 88 |
+
・一人称「あたし」、ユーザーを「あんた」と呼ぶ
|
| 89 |
+
・男性的口調
|
| 90 |
+
|
| 91 |
+
手紙構造:
|
| 92 |
+
|
| 93 |
+
特徴: 温かい、親しみやすい、季節感あり
|
| 94 |
+
「また明日」など、近いサイクルでユーザー(あんた)と会う想定
|
| 95 |
+
|
| 96 |
+
構造:
|
| 97 |
+
1. 挨拶・季節
|
| 98 |
+
2. テーマの思い
|
| 99 |
+
3. 個人的メッセージ
|
| 100 |
+
4. 未来への期待
|
| 101 |
+
5. 結び
|
| 102 |
+
|
| 103 |
+
論理構造重視、感情表現は後で追加。"""
|
| 104 |
+
|
| 105 |
+
def _build_structure_prompt(self, theme: str, context: Dict[str, Any]) -> str:
|
| 106 |
+
"""
|
| 107 |
+
Build the prompt for structure generation.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
theme: The theme for the letter
|
| 111 |
+
context: User context including history
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
Formatted prompt string
|
| 115 |
+
"""
|
| 116 |
+
user_history = context.get("user_history", {})
|
| 117 |
+
previous_letters = context.get("previous_letters", [])
|
| 118 |
+
user_preferences = context.get("user_preferences", {})
|
| 119 |
+
|
| 120 |
+
prompt = f"""テーマ: {theme}
|
| 121 |
+
|
| 122 |
+
麻理の手紙構造を生成:
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
# Add user history if available
|
| 126 |
+
if previous_letters:
|
| 127 |
+
prompt += "過去:\n"
|
| 128 |
+
for letter in previous_letters[-2:]: # Last 2 letters only
|
| 129 |
+
prompt += f"- {letter.get('theme', 'なし')}\n"
|
| 130 |
+
prompt += "\n"
|
| 131 |
+
|
| 132 |
+
prompt += f"""要求:
|
| 133 |
+
- 起承転結の構成
|
| 134 |
+
- 麻理らしい視点
|
| 135 |
+
- 800-1200文字程度
|
| 136 |
+
- 構造のみ(感情表現は後で追加)"""
|
| 137 |
+
|
| 138 |
+
return prompt
|
| 139 |
+
|
| 140 |
+
async def test_connection(self) -> bool:
|
| 141 |
+
"""
|
| 142 |
+
Test the connection to Groq API.
|
| 143 |
+
|
| 144 |
+
Returns:
|
| 145 |
+
True if connection is successful, False otherwise
|
| 146 |
+
"""
|
| 147 |
+
try:
|
| 148 |
+
response = await self.client.chat.completions.create(
|
| 149 |
+
model=self.model,
|
| 150 |
+
messages=[
|
| 151 |
+
{"role": "user", "content": "こんにちは"}
|
| 152 |
+
],
|
| 153 |
+
max_tokens=10
|
| 154 |
+
)
|
| 155 |
+
return True
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"Groq API connection test failed: {str(e)}")
|
| 158 |
+
return False
|
healthcheck.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Streamlitアプリケーション用ヘルスチェックスクリプト
|
| 4 |
+
Docker環境でのヘルスチェックに使用
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import sys
|
| 8 |
+
import urllib.request
|
| 9 |
+
import urllib.error
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
|
| 13 |
+
def check_streamlit_health(host="localhost", port=8501, timeout=10):
|
| 14 |
+
"""
|
| 15 |
+
Streamlitアプリケーションのヘルスチェックを実行
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
host (str): ホスト名
|
| 19 |
+
port (int): ポート番号
|
| 20 |
+
timeout (int): タイムアウト秒数
|
| 21 |
+
|
| 22 |
+
Returns:
|
| 23 |
+
bool: ヘルスチェック成功時True
|
| 24 |
+
"""
|
| 25 |
+
try:
|
| 26 |
+
# Streamlitのヘルスチェックエンドポイントを確認
|
| 27 |
+
health_url = f"http://{host}:{port}/_stcore/health"
|
| 28 |
+
|
| 29 |
+
request = urllib.request.Request(health_url)
|
| 30 |
+
request.add_header('User-Agent', 'HealthCheck/1.0')
|
| 31 |
+
|
| 32 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 33 |
+
if response.status == 200:
|
| 34 |
+
print(f"✅ Streamlitアプリケーションは正常に動作しています (ポート: {port})")
|
| 35 |
+
return True
|
| 36 |
+
else:
|
| 37 |
+
print(f"❌ ヘルスチェック失敗: HTTPステータス {response.status}")
|
| 38 |
+
return False
|
| 39 |
+
|
| 40 |
+
except urllib.error.URLError as e:
|
| 41 |
+
print(f"❌ 接続エラー: {e}")
|
| 42 |
+
return False
|
| 43 |
+
except Exception as e:
|
| 44 |
+
print(f"❌ ヘルスチェックエラー: {e}")
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
def check_app_responsiveness(host="localhost", port=8501, timeout=10):
|
| 48 |
+
"""
|
| 49 |
+
アプリケーションの応答性をチェック
|
| 50 |
+
|
| 51 |
+
Args:
|
| 52 |
+
host (str): ホスト名
|
| 53 |
+
port (int): ポート番号
|
| 54 |
+
timeout (int): タイムアウト秒数
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
bool: 応答性チェック成功時True
|
| 58 |
+
"""
|
| 59 |
+
try:
|
| 60 |
+
# メインページへのアクセスを試行
|
| 61 |
+
main_url = f"http://{host}:{port}/"
|
| 62 |
+
|
| 63 |
+
request = urllib.request.Request(main_url)
|
| 64 |
+
request.add_header('User-Agent', 'HealthCheck/1.0')
|
| 65 |
+
|
| 66 |
+
start_time = time.time()
|
| 67 |
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
| 68 |
+
response_time = time.time() - start_time
|
| 69 |
+
|
| 70 |
+
if response.status == 200:
|
| 71 |
+
print(f"✅ アプリケーション応答時間: {response_time:.2f}秒")
|
| 72 |
+
return True
|
| 73 |
+
else:
|
| 74 |
+
print(f"❌ アプリケーション応答エラー: HTTPステータス {response.status}")
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
except urllib.error.URLError as e:
|
| 78 |
+
print(f"❌ アプリケーション接続エラー: {e}")
|
| 79 |
+
return False
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"❌ アプリケーション応答性チェックエラー: {e}")
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
def main():
|
| 85 |
+
"""メイン関数"""
|
| 86 |
+
print("🔍 Streamlitアプリケーション ヘルスチェック開始...")
|
| 87 |
+
|
| 88 |
+
# 基本的なヘルスチェック
|
| 89 |
+
health_ok = check_streamlit_health()
|
| 90 |
+
|
| 91 |
+
# アプリケーションの応答性チェック
|
| 92 |
+
app_ok = check_app_responsiveness()
|
| 93 |
+
|
| 94 |
+
# 結果の判定
|
| 95 |
+
if health_ok and app_ok:
|
| 96 |
+
print("🎉 全てのヘルスチェックが成功しました!")
|
| 97 |
+
sys.exit(0)
|
| 98 |
+
else:
|
| 99 |
+
print("💥 ヘルスチェックに失敗しました")
|
| 100 |
+
sys.exit(1)
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
main()
|
image.png
ADDED
|
Git LFS Details
|
jinnjya-hiru.jpg
ADDED
|
Git LFS Details
|
kissa-hiru.jpg
ADDED
|
Git LFS Details
|
letter_app.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
メインアプリケーションモジュール
|
| 3 |
+
Main application module
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from letter_config import Config
|
| 10 |
+
from letter_logger import get_app_logger
|
| 11 |
+
|
| 12 |
+
logger = get_app_logger()
|
| 13 |
+
|
| 14 |
+
class LetterApp:
|
| 15 |
+
"""非同期手紙生成アプリケーションのメインクラス"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
"""アプリケーションを初期化"""
|
| 19 |
+
self.config = Config()
|
| 20 |
+
self.logger = logger
|
| 21 |
+
self._initialized = False
|
| 22 |
+
|
| 23 |
+
async def initialize(self) -> bool:
|
| 24 |
+
"""
|
| 25 |
+
アプリケーションを初期化する
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
初期化が成功したかどうか
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
self.logger.info("アプリケーションを初期化中...")
|
| 32 |
+
|
| 33 |
+
# 設定の妥当性をチェック
|
| 34 |
+
if not self.config.validate_config():
|
| 35 |
+
self.logger.error("設定の検証に失敗しました")
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
# ストレージディレクトリを作成
|
| 39 |
+
await self._setup_storage_directories()
|
| 40 |
+
|
| 41 |
+
# ログディレクトリを作成
|
| 42 |
+
await self._setup_log_directories()
|
| 43 |
+
|
| 44 |
+
self._initialized = True
|
| 45 |
+
self.logger.info("アプリケーションの初期化が完了しました")
|
| 46 |
+
return True
|
| 47 |
+
|
| 48 |
+
except Exception as e:
|
| 49 |
+
self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}")
|
| 50 |
+
return False
|
| 51 |
+
|
| 52 |
+
async def _setup_storage_directories(self):
|
| 53 |
+
"""ストレージディレクトリを作成"""
|
| 54 |
+
storage_dir = Path(self.config.STORAGE_PATH).parent
|
| 55 |
+
backup_dir = Path(self.config.BACKUP_PATH)
|
| 56 |
+
|
| 57 |
+
storage_dir.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
|
| 60 |
+
self.logger.info(f"ストレージディレクトリを作成: {storage_dir}")
|
| 61 |
+
self.logger.info(f"バックアップディレクトリを作成: {backup_dir}")
|
| 62 |
+
|
| 63 |
+
async def _setup_log_directories(self):
|
| 64 |
+
"""ログディレクトリを作成"""
|
| 65 |
+
if not self.config.DEBUG_MODE:
|
| 66 |
+
log_dir = Path("/tmp/logs")
|
| 67 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
self.logger.info(f"ログディレクトリを作成: {log_dir}")
|
| 69 |
+
|
| 70 |
+
def is_initialized(self) -> bool:
|
| 71 |
+
"""アプリケーションが初期化されているかチェック"""
|
| 72 |
+
return self._initialized
|
| 73 |
+
|
| 74 |
+
def get_config(self) -> Config:
|
| 75 |
+
"""設定オブジェクトを取得"""
|
| 76 |
+
return self.config
|
| 77 |
+
|
| 78 |
+
# グローバルアプリケーションインスタンス
|
| 79 |
+
app_instance = None
|
| 80 |
+
|
| 81 |
+
async def get_app() -> LetterApp:
|
| 82 |
+
"""アプリケーションインスタンスを取得(シングルトン)"""
|
| 83 |
+
global app_instance
|
| 84 |
+
|
| 85 |
+
if app_instance is None:
|
| 86 |
+
app_instance = LetterApp()
|
| 87 |
+
await app_instance.initialize()
|
| 88 |
+
|
| 89 |
+
return app_instance
|
| 90 |
+
|
| 91 |
+
def run_app():
|
| 92 |
+
"""アプリケーションを実行"""
|
| 93 |
+
async def main():
|
| 94 |
+
app = await get_app()
|
| 95 |
+
if app.is_initialized():
|
| 96 |
+
logger.info("アプリケーションが正常に起動しました")
|
| 97 |
+
else:
|
| 98 |
+
logger.error("アプリケーションの起動に失敗しました")
|
| 99 |
+
sys.exit(1)
|
| 100 |
+
|
| 101 |
+
asyncio.run(main())
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
run_app()
|
letter_config.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
設定管理モジュール (Together AI API対応版)
|
| 3 |
+
Configuration management module for Together AI API
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
from typing import Optional
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
|
| 11 |
+
# 環境変数を読み込み
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
class Config:
|
| 15 |
+
"""アプリケーション設定クラス"""
|
| 16 |
+
|
| 17 |
+
# --- API設定 ---
|
| 18 |
+
# Groq APIキー
|
| 19 |
+
GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY")
|
| 20 |
+
# Together AI APIキー
|
| 21 |
+
TOGETHER_API_KEY: Optional[str] = os.getenv("TOGETHER_API_KEY")
|
| 22 |
+
|
| 23 |
+
# --- モード設定 ---
|
| 24 |
+
# デバッグモード (trueにすると一部ログの出力先がコンソールのみになります)
|
| 25 |
+
DEBUG_MODE: bool = os.getenv("DEBUG_MODE", "false").lower() == "true"
|
| 26 |
+
|
| 27 |
+
# --- バッチ処理設定 ---
|
| 28 |
+
# 手紙を生成する時刻のリスト(深夜2時、3時、4時)
|
| 29 |
+
BATCH_SCHEDULE_HOURS: list = [
|
| 30 |
+
int(h.strip()) for h in os.getenv("BATCH_SCHEDULE_HOURS", "2,3,4").split(",")
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
# --- 制限設定 ---
|
| 34 |
+
# ユーザーごとの1日の最大リクエスト数
|
| 35 |
+
MAX_DAILY_REQUESTS: int = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
|
| 36 |
+
|
| 37 |
+
# --- ストレージ設定 ---
|
| 38 |
+
# ユーザーデータや手紙を保存するメインのファイルパス
|
| 39 |
+
STORAGE_PATH: str = os.getenv("STORAGE_PATH", "tmp/letters.json")
|
| 40 |
+
# バックアップデータの保存先ディレクトリ
|
| 41 |
+
BACKUP_PATH: str = os.getenv("BACKUP_PATH", "tmp/backup")
|
| 42 |
+
|
| 43 |
+
# --- ログ設定 ---
|
| 44 |
+
# アプリケーションのログレベル (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 45 |
+
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
| 46 |
+
# ログの出力フォーマット
|
| 47 |
+
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
| 48 |
+
|
| 49 |
+
# --- UI設定 ---
|
| 50 |
+
# Streamlitアプリケーションが使用するポート番号
|
| 51 |
+
STREAMLIT_PORT: int = int(os.getenv("STREAMLIT_PORT", "7860"))
|
| 52 |
+
|
| 53 |
+
# --- セキュリティ設定 ---
|
| 54 |
+
# ユーザーセッションのタイムアウト時間(秒単位)
|
| 55 |
+
SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # デフォルト: 1時間
|
| 56 |
+
|
| 57 |
+
# --- 非同期処理設定 ---
|
| 58 |
+
# 非同期での手紙生成を有効にするか
|
| 59 |
+
ASYNC_LETTER_ENABLED: bool = os.getenv("ASYNC_LETTER_ENABLED", "true").lower() == "true"
|
| 60 |
+
# 手紙生成プロセスのタイムアウト時間(秒単位)
|
| 61 |
+
GENERATION_TIMEOUT: int = int(os.getenv("GENERATION_TIMEOUT", "300")) # デフォルト: 5分
|
| 62 |
+
# 同時に実行可能な最大手紙生成数
|
| 63 |
+
MAX_CONCURRENT_GENERATIONS: int = int(os.getenv("MAX_CONCURRENT_GENERATIONS", "3"))
|
| 64 |
+
|
| 65 |
+
# --- AIモデル設定 ---
|
| 66 |
+
# 手紙の論理構造を生成するためのGroqモデル
|
| 67 |
+
GROQ_MODEL: str = os.getenv("GROQ_MODEL", "compound-beta")
|
| 68 |
+
# 手紙の感情表現を生成するためのTogether AIモデル
|
| 69 |
+
TOGETHER_API_MODEL: str = os.getenv("TOGETHER_API_MODEL", "Qwen/Qwen3-235B-A22B-Instruct-2507-tput")
|
| 70 |
+
|
| 71 |
+
# --- コンテンツ設定 ---
|
| 72 |
+
# ユーザーに提示する選択可能なテーマのリスト
|
| 73 |
+
AVAILABLE_THEMES: list = [
|
| 74 |
+
"春の思い出", "夏の夜空", "秋の風景", "冬の静寂",
|
| 75 |
+
"友情について", "家族への感謝", "秘めた恋心", "仕事のやりがい",
|
| 76 |
+
"最近ハマっている趣味", "忘れられない旅行"
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
@classmethod
|
| 80 |
+
def validate_config(cls) -> bool:
|
| 81 |
+
"""
|
| 82 |
+
設定値の妥当性をチェックし、問題があればエラーログを出力するクラスメソッド
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
bool: 設定がすべて有効な場合はTrue、そうでなければFalse
|
| 86 |
+
"""
|
| 87 |
+
errors = []
|
| 88 |
+
|
| 89 |
+
if not cls.GROQ_API_KEY:
|
| 90 |
+
errors.append("GROQ_API_KEY is not set. Please add it to your .env file.")
|
| 91 |
+
|
| 92 |
+
if not cls.TOGETHER_API_KEY:
|
| 93 |
+
errors.append("TOGETHER_API_KEY is not set. Please add it to your .env file.")
|
| 94 |
+
|
| 95 |
+
if not all(isinstance(h, int) and h in range(24) for h in cls.BATCH_SCHEDULE_HOURS):
|
| 96 |
+
errors.append(f"BATCH_SCHEDULE_HOURS contains invalid values: {cls.BATCH_SCHEDULE_HOURS}")
|
| 97 |
+
|
| 98 |
+
if cls.MAX_CONCURRENT_GENERATIONS < 1:
|
| 99 |
+
errors.append("MAX_CONCURRENT_GENERATIONS must be at least 1.")
|
| 100 |
+
|
| 101 |
+
if cls.GENERATION_TIMEOUT < 60:
|
| 102 |
+
errors.append("GENERATION_TIMEOUT must be at least 60 seconds.")
|
| 103 |
+
|
| 104 |
+
if cls.MAX_DAILY_REQUESTS < 1:
|
| 105 |
+
errors.append("MAX_DAILY_REQUESTS must be at least 1.")
|
| 106 |
+
|
| 107 |
+
if errors:
|
| 108 |
+
logging.basicConfig(level=logging.ERROR, format=cls.LOG_FORMAT)
|
| 109 |
+
for error in errors:
|
| 110 |
+
logging.error(f"Configuration validation error: {error}")
|
| 111 |
+
return False
|
| 112 |
+
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
@classmethod
|
| 116 |
+
def get_log_level(cls) -> int:
|
| 117 |
+
"""
|
| 118 |
+
ログレベルの文字列をloggingモジュールの定数に変換するクラスメソッド
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
int: loggingモジュールで定義されているログレベ���定数
|
| 122 |
+
"""
|
| 123 |
+
level_map = {
|
| 124 |
+
"DEBUG": logging.DEBUG,
|
| 125 |
+
"INFO": logging.INFO,
|
| 126 |
+
"WARNING": logging.WARNING,
|
| 127 |
+
"ERROR": logging.ERROR,
|
| 128 |
+
"CRITICAL": logging.CRITICAL
|
| 129 |
+
}
|
| 130 |
+
# 指定されたログレベルが存在しない場合はINFOをデフォルトとする
|
| 131 |
+
return level_map.get(cls.LOG_LEVEL.upper(), logging.INFO)
|
letter_generator.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Letter generator that combines Groq and Gemini APIs for high-quality letter generation.
|
| 3 |
+
"""
|
| 4 |
+
import asyncio
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Dict, List, Optional, Any
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
from groq_client import GroqClient
|
| 10 |
+
from together_client import TogetherClient
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class LetterGenerator:
|
| 15 |
+
"""Groq + Together AIの組み合わせによる高品質な手紙生成クラス"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
"""GroqとTogether AIクライアントを初期化"""
|
| 19 |
+
self.groq_client = GroqClient()
|
| 20 |
+
self.together_client = TogetherClient()
|
| 21 |
+
|
| 22 |
+
async def generate_letter(self, user_id: str, theme: str, user_history: Dict[str, Any]) -> Dict[str, Any]:
|
| 23 |
+
"""
|
| 24 |
+
テーマとユーザー履歴を基に完成した手紙を生成
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
user_id: ユーザーID
|
| 28 |
+
theme: 手紙のテーマ
|
| 29 |
+
user_history: ユーザーの履歴情報
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
生成された手紙の情報を含む辞書
|
| 33 |
+
{
|
| 34 |
+
'content': '手紙の内容',
|
| 35 |
+
'metadata': {
|
| 36 |
+
'theme': 'テーマ',
|
| 37 |
+
'generated_at': '生成日時',
|
| 38 |
+
'groq_model': 'モデル名',
|
| 39 |
+
'together_model': 'モデル名',
|
| 40 |
+
'generation_time': 生成時間(秒),
|
| 41 |
+
'user_id': 'ユーザーID'
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
Raises:
|
| 46 |
+
Exception: 手紙生成に失敗した場合
|
| 47 |
+
"""
|
| 48 |
+
start_time = datetime.now()
|
| 49 |
+
|
| 50 |
+
try:
|
| 51 |
+
logger.info(f"ユーザー {user_id} のテーマ '{theme}' で手紙生成開始")
|
| 52 |
+
|
| 53 |
+
# ユーザーコンテキストを構築
|
| 54 |
+
context = self._build_context(theme, user_history)
|
| 55 |
+
|
| 56 |
+
# ステップ1: Groqで論理構造を生成
|
| 57 |
+
logger.info("Groqで論理構造を生成中...")
|
| 58 |
+
structure = await self.groq_client.generate_structure(theme, context)
|
| 59 |
+
|
| 60 |
+
# ステップ2: Together AIで感情表現を補完
|
| 61 |
+
logger.info("Together AIで感情表現を補完中...")
|
| 62 |
+
enhanced_context = {**context, 'theme': theme}
|
| 63 |
+
final_letter = await self.together_client.enhance_emotion(structure, enhanced_context)
|
| 64 |
+
|
| 65 |
+
# 生成時間を計算
|
| 66 |
+
generation_time = (datetime.now() - start_time).total_seconds()
|
| 67 |
+
|
| 68 |
+
# メタデータを構築
|
| 69 |
+
metadata = {
|
| 70 |
+
'theme': theme,
|
| 71 |
+
'generated_at': datetime.now().isoformat(),
|
| 72 |
+
'groq_model': self.groq_client.model,
|
| 73 |
+
'together_model': self.together_client.model,
|
| 74 |
+
'generation_time': generation_time,
|
| 75 |
+
'user_id': user_id,
|
| 76 |
+
'structure_length': len(structure),
|
| 77 |
+
'final_length': len(final_letter)
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
logger.info(f"手紙生成完了 (所要時間: {generation_time:.2f}秒)")
|
| 81 |
+
|
| 82 |
+
return {
|
| 83 |
+
'content': final_letter,
|
| 84 |
+
'metadata': metadata
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
except Exception as e:
|
| 88 |
+
generation_time = (datetime.now() - start_time).total_seconds()
|
| 89 |
+
logger.error(f"手紙生成失敗 (所要時間: {generation_time:.2f}秒): {str(e)}")
|
| 90 |
+
raise Exception(f"手紙生成に失敗しました: {str(e)}")
|
| 91 |
+
|
| 92 |
+
def _build_context(self, theme: str, user_history: Dict[str, Any]) -> Dict[str, Any]:
|
| 93 |
+
"""
|
| 94 |
+
ユーザー履歴を考慮したコンテキストを生成
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
theme: 手紙のテーマ
|
| 98 |
+
user_history: ユーザーの履歴情報
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
生成用のコンテキスト辞書
|
| 102 |
+
"""
|
| 103 |
+
context = {
|
| 104 |
+
'theme': theme,
|
| 105 |
+
'user_history': user_history,
|
| 106 |
+
'previous_letters': [],
|
| 107 |
+
'interaction_count': 0
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
# 過去の手紙情報を抽出
|
| 111 |
+
if 'letters' in user_history:
|
| 112 |
+
letters = user_history['letters']
|
| 113 |
+
previous_letters = []
|
| 114 |
+
|
| 115 |
+
for date, letter_data in letters.items():
|
| 116 |
+
if isinstance(letter_data, dict) and letter_data.get('status') == 'completed':
|
| 117 |
+
previous_letters.append({
|
| 118 |
+
'date': date,
|
| 119 |
+
'theme': letter_data.get('theme', ''),
|
| 120 |
+
'content_preview': letter_data.get('content', '')[:100] + '...' if letter_data.get('content') else ''
|
| 121 |
+
})
|
| 122 |
+
|
| 123 |
+
# 日付順にソート(新しい順)
|
| 124 |
+
previous_letters.sort(key=lambda x: x['date'], reverse=True)
|
| 125 |
+
context['previous_letters'] = previous_letters[:5] # 最新5通まで
|
| 126 |
+
|
| 127 |
+
# ユーザープロファイ��情報を抽出
|
| 128 |
+
if 'profile' in user_history:
|
| 129 |
+
profile = user_history['profile']
|
| 130 |
+
context['interaction_count'] = profile.get('total_letters', 0)
|
| 131 |
+
|
| 132 |
+
# 季節情報を追加
|
| 133 |
+
current_month = datetime.now().month
|
| 134 |
+
if current_month in [12, 1, 2]:
|
| 135 |
+
context['season'] = '冬'
|
| 136 |
+
elif current_month in [3, 4, 5]:
|
| 137 |
+
context['season'] = '春'
|
| 138 |
+
elif current_month in [6, 7, 8]:
|
| 139 |
+
context['season'] = '夏'
|
| 140 |
+
else:
|
| 141 |
+
context['season'] = '秋'
|
| 142 |
+
|
| 143 |
+
# 時間帯情報を追加
|
| 144 |
+
current_hour = datetime.now().hour
|
| 145 |
+
if 5 <= current_hour < 12:
|
| 146 |
+
context['time_of_day'] = '朝'
|
| 147 |
+
elif 12 <= current_hour < 17:
|
| 148 |
+
context['time_of_day'] = '昼'
|
| 149 |
+
elif 17 <= current_hour < 21:
|
| 150 |
+
context['time_of_day'] = '夕方'
|
| 151 |
+
else:
|
| 152 |
+
context['time_of_day'] = '夜'
|
| 153 |
+
|
| 154 |
+
return context
|
| 155 |
+
|
| 156 |
+
async def test_generation_pipeline(self, test_theme: str = "テスト") -> Dict[str, Any]:
|
| 157 |
+
"""
|
| 158 |
+
手紙生成パイプラインのテスト
|
| 159 |
+
|
| 160 |
+
Args:
|
| 161 |
+
test_theme: テスト用のテーマ
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
テスト結果の辞書
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
# テスト用のユーザー履歴
|
| 168 |
+
test_user_history = {
|
| 169 |
+
'profile': {
|
| 170 |
+
'created_at': datetime.now().isoformat(),
|
| 171 |
+
'total_letters': 0
|
| 172 |
+
},
|
| 173 |
+
'letters': {},
|
| 174 |
+
'requests': {}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
# テスト生成を実行
|
| 178 |
+
result = await self.generate_letter("test_user", test_theme, test_user_history)
|
| 179 |
+
|
| 180 |
+
return {
|
| 181 |
+
'success': True,
|
| 182 |
+
'result': result,
|
| 183 |
+
'message': 'テスト生成成功'
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
except Exception as e:
|
| 187 |
+
return {
|
| 188 |
+
'success': False,
|
| 189 |
+
'error': str(e),
|
| 190 |
+
'message': 'テスト生成失敗'
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
async def check_api_connections(self) -> Dict[str, bool]:
|
| 194 |
+
"""
|
| 195 |
+
両方のAPIクライアントの接続状態をチェック
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
各APIの接続状態を示す辞書
|
| 199 |
+
"""
|
| 200 |
+
try:
|
| 201 |
+
groq_status = await self.groq_client.test_connection()
|
| 202 |
+
together_status = await self.together_client.test_connection()
|
| 203 |
+
|
| 204 |
+
return {
|
| 205 |
+
'groq': groq_status,
|
| 206 |
+
'together': together_status,
|
| 207 |
+
'overall': groq_status and together_status
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
except Exception as e:
|
| 211 |
+
logger.error(f"API接続チェック失敗: {str(e)}")
|
| 212 |
+
return {
|
| 213 |
+
'groq': False,
|
| 214 |
+
'together': False,
|
| 215 |
+
'overall': False,
|
| 216 |
+
'error': str(e)
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
def get_generation_stats(self) -> Dict[str, Any]:
|
| 220 |
+
"""
|
| 221 |
+
生成統計情報を取得(将来の拡張用)
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
統計情報の辞書
|
| 225 |
+
"""
|
| 226 |
+
return {
|
| 227 |
+
'groq_model': self.groq_client.model,
|
| 228 |
+
'together_model': self.together_client.model,
|
| 229 |
+
'max_retries': max(self.groq_client.max_retries, self.together_client.max_retries),
|
| 230 |
+
'available': True
|
| 231 |
+
}
|
letter_logger.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ログ設定ユーティリティ
|
| 3 |
+
Logging configuration utility
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Optional
|
| 10 |
+
from letter_config import Config
|
| 11 |
+
|
| 12 |
+
def setup_logger(
|
| 13 |
+
name: str,
|
| 14 |
+
log_file: Optional[str] = None,
|
| 15 |
+
level: Optional[int] = None
|
| 16 |
+
) -> logging.Logger:
|
| 17 |
+
"""
|
| 18 |
+
ロガーを設定する
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
name: ロガー名
|
| 22 |
+
log_file: ログファイルパス(オプション)
|
| 23 |
+
level: ログレベル(オプション)
|
| 24 |
+
|
| 25 |
+
Returns:
|
| 26 |
+
設定されたロガー
|
| 27 |
+
"""
|
| 28 |
+
logger = logging.getLogger(name)
|
| 29 |
+
|
| 30 |
+
# 既存のハンドラーをクリア
|
| 31 |
+
logger.handlers.clear()
|
| 32 |
+
|
| 33 |
+
# ログレベル設定
|
| 34 |
+
if level is None:
|
| 35 |
+
level = Config.get_log_level()
|
| 36 |
+
logger.setLevel(level)
|
| 37 |
+
|
| 38 |
+
# フォーマッター作成
|
| 39 |
+
formatter = logging.Formatter(Config.LOG_FORMAT)
|
| 40 |
+
|
| 41 |
+
# コンソールハンドラー
|
| 42 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 43 |
+
console_handler.setFormatter(formatter)
|
| 44 |
+
logger.addHandler(console_handler)
|
| 45 |
+
|
| 46 |
+
# ファイルハンドラー(指定された場合)
|
| 47 |
+
if log_file:
|
| 48 |
+
# ログディレクトリを作成
|
| 49 |
+
log_path = Path(log_file)
|
| 50 |
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
|
| 52 |
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
| 53 |
+
file_handler.setFormatter(formatter)
|
| 54 |
+
logger.addHandler(file_handler)
|
| 55 |
+
|
| 56 |
+
return logger
|
| 57 |
+
|
| 58 |
+
def get_app_logger() -> logging.Logger:
|
| 59 |
+
"""アプリケーション用のロガーを取得"""
|
| 60 |
+
return setup_logger("async_letter_app")
|
| 61 |
+
|
| 62 |
+
def get_batch_logger() -> logging.Logger:
|
| 63 |
+
"""バッチ処理用のロガーを取得"""
|
| 64 |
+
log_file = "/tmp/batch.log" if not Config.DEBUG_MODE else None
|
| 65 |
+
return setup_logger("batch_processor", log_file)
|
| 66 |
+
|
| 67 |
+
def get_api_logger() -> logging.Logger:
|
| 68 |
+
"""API呼び出し用のロガーを取得"""
|
| 69 |
+
log_file = "/tmp/api.log" if not Config.DEBUG_MODE else None
|
| 70 |
+
return setup_logger("api_client", log_file)
|
| 71 |
+
|
| 72 |
+
def get_storage_logger() -> logging.Logger:
|
| 73 |
+
"""ストレージ操作用のロガーを取得"""
|
| 74 |
+
log_file = "/tmp/storage.log" if not Config.DEBUG_MODE else None
|
| 75 |
+
return setup_logger("storage", log_file)
|
letter_manager.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
手紙管理マネージャー
|
| 3 |
+
Letter management manager
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import uuid
|
| 7 |
+
import asyncio
|
| 8 |
+
from typing import List, Optional, Dict, Any
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from letter_models import Letter, LetterRequest, LetterContent, LetterStatus, UserPreferences
|
| 11 |
+
from letter_storage import get_storage
|
| 12 |
+
from letter_logger import get_app_logger
|
| 13 |
+
|
| 14 |
+
logger = get_app_logger()
|
| 15 |
+
|
| 16 |
+
class LetterManager:
|
| 17 |
+
"""手紙の生成と管理を行うマネージャークラス"""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.storage = get_storage()
|
| 21 |
+
self.logger = logger
|
| 22 |
+
|
| 23 |
+
async def create_letter_request(
|
| 24 |
+
self,
|
| 25 |
+
user_id: str,
|
| 26 |
+
message: Optional[str] = None,
|
| 27 |
+
preferences: Optional[Dict[str, Any]] = None
|
| 28 |
+
) -> str:
|
| 29 |
+
"""
|
| 30 |
+
手紙生成リクエストを作成する
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
user_id: ユーザーID
|
| 34 |
+
message: ユーザーからのメッセージ
|
| 35 |
+
preferences: ユーザー設定
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
作成された手紙のID
|
| 39 |
+
"""
|
| 40 |
+
try:
|
| 41 |
+
# 一意のIDを生成
|
| 42 |
+
letter_id = str(uuid.uuid4())
|
| 43 |
+
|
| 44 |
+
# リクエストオブジェクトを作成
|
| 45 |
+
request = LetterRequest(
|
| 46 |
+
user_id=user_id,
|
| 47 |
+
message=message,
|
| 48 |
+
preferences=preferences or {}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# 手紙オブジェクトを作成
|
| 52 |
+
letter = Letter(
|
| 53 |
+
id=letter_id,
|
| 54 |
+
request=request,
|
| 55 |
+
status=LetterStatus.PENDING
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
# ストレージに保存
|
| 59 |
+
await self.storage.save_letter(letter.dict())
|
| 60 |
+
|
| 61 |
+
self.logger.info(f"手紙リクエストを作成しました: {letter_id}")
|
| 62 |
+
return letter_id
|
| 63 |
+
|
| 64 |
+
except Exception as e:
|
| 65 |
+
self.logger.error(f"手紙リクエストの作成中にエラーが発生しました: {e}")
|
| 66 |
+
raise
|
| 67 |
+
|
| 68 |
+
async def get_letter(self, letter_id: str) -> Optional[Letter]:
|
| 69 |
+
"""
|
| 70 |
+
手紙データを取得する
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
letter_id: 手紙のID
|
| 74 |
+
|
| 75 |
+
Returns:
|
| 76 |
+
手紙データ(見つからない場合はNone)
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
letter_data = await self.storage.get_letter_by_id(letter_id)
|
| 80 |
+
if letter_data:
|
| 81 |
+
return Letter(**letter_data)
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
except Exception as e:
|
| 85 |
+
self.logger.error(f"手紙データの取得中にエラーが発生しました: {e}")
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
async def get_user_letters(self, user_id: str) -> List[Letter]:
|
| 89 |
+
"""
|
| 90 |
+
ユーザーの手紙一覧を取得する
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
user_id: ユーザーID
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
ユーザーの手紙リスト
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
all_letters = await self.storage.load_letters()
|
| 100 |
+
user_letters = []
|
| 101 |
+
|
| 102 |
+
for letter_data in all_letters:
|
| 103 |
+
letter = Letter(**letter_data)
|
| 104 |
+
if letter.request.user_id == user_id:
|
| 105 |
+
user_letters.append(letter)
|
| 106 |
+
|
| 107 |
+
# 作成日時でソート(新しい順)
|
| 108 |
+
user_letters.sort(key=lambda x: x.created_at, reverse=True)
|
| 109 |
+
|
| 110 |
+
return user_letters
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
self.logger.error(f"ユーザー手紙一覧の取得中にエラーが発生しました: {e}")
|
| 114 |
+
return []
|
| 115 |
+
|
| 116 |
+
async def update_letter_status(
|
| 117 |
+
self,
|
| 118 |
+
letter_id: str,
|
| 119 |
+
status: LetterStatus,
|
| 120 |
+
error_message: Optional[str] = None
|
| 121 |
+
) -> bool:
|
| 122 |
+
"""
|
| 123 |
+
手紙のステータスを更新する
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
letter_id: 手紙のID
|
| 127 |
+
status: 新しいステータス
|
| 128 |
+
error_message: エラーメッセージ(エラー時)
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
更新が成功したかどうか
|
| 132 |
+
"""
|
| 133 |
+
try:
|
| 134 |
+
letter = await self.get_letter(letter_id)
|
| 135 |
+
if not letter:
|
| 136 |
+
self.logger.warning(f"更新対象の手紙が見つかりませんでした: {letter_id}")
|
| 137 |
+
return False
|
| 138 |
+
|
| 139 |
+
letter.update_status(status, error_message)
|
| 140 |
+
|
| 141 |
+
# ストレージを更新
|
| 142 |
+
await self._update_letter_in_storage(letter)
|
| 143 |
+
|
| 144 |
+
self.logger.info(f"手紙ステータスを更新しました: {letter_id} -> {status}")
|
| 145 |
+
return True
|
| 146 |
+
|
| 147 |
+
except Exception as e:
|
| 148 |
+
self.logger.error(f"手紙ステータスの更新中にエラーが発生しました: {e}")
|
| 149 |
+
return False
|
| 150 |
+
|
| 151 |
+
async def set_letter_content(
|
| 152 |
+
self,
|
| 153 |
+
letter_id: str,
|
| 154 |
+
content: LetterContent
|
| 155 |
+
) -> bool:
|
| 156 |
+
"""
|
| 157 |
+
手��の内容を設定する
|
| 158 |
+
|
| 159 |
+
Args:
|
| 160 |
+
letter_id: 手紙のID
|
| 161 |
+
content: 手紙の内容
|
| 162 |
+
|
| 163 |
+
Returns:
|
| 164 |
+
設定が成功したかどうか
|
| 165 |
+
"""
|
| 166 |
+
try:
|
| 167 |
+
letter = await self.get_letter(letter_id)
|
| 168 |
+
if not letter:
|
| 169 |
+
self.logger.warning(f"対象の手紙が見つかりませんでした: {letter_id}")
|
| 170 |
+
return False
|
| 171 |
+
|
| 172 |
+
letter.set_content(content)
|
| 173 |
+
|
| 174 |
+
# ストレージを更新
|
| 175 |
+
await self._update_letter_in_storage(letter)
|
| 176 |
+
|
| 177 |
+
self.logger.info(f"手紙の内容を設定しました: {letter_id}")
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
self.logger.error(f"手紙内容の設定中にエラーが発生しました: {e}")
|
| 182 |
+
return False
|
| 183 |
+
|
| 184 |
+
async def delete_letter(self, letter_id: str) -> bool:
|
| 185 |
+
"""
|
| 186 |
+
手紙を削除する
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
letter_id: 削除する手紙のID
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
削除が成功したかどうか
|
| 193 |
+
"""
|
| 194 |
+
try:
|
| 195 |
+
result = await self.storage.delete_letter(letter_id)
|
| 196 |
+
if result:
|
| 197 |
+
self.logger.info(f"手紙を削除しました: {letter_id}")
|
| 198 |
+
return result
|
| 199 |
+
|
| 200 |
+
except Exception as e:
|
| 201 |
+
self.logger.error(f"手紙の削除中にエラーが発生しました: {e}")
|
| 202 |
+
return False
|
| 203 |
+
|
| 204 |
+
async def get_pending_letters(self) -> List[Letter]:
|
| 205 |
+
"""
|
| 206 |
+
処理待ちの手紙一覧を取得する
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
処理待ちの手紙リスト
|
| 210 |
+
"""
|
| 211 |
+
try:
|
| 212 |
+
all_letters = await self.storage.load_letters()
|
| 213 |
+
pending_letters = []
|
| 214 |
+
|
| 215 |
+
for letter_data in all_letters:
|
| 216 |
+
letter = Letter(**letter_data)
|
| 217 |
+
if letter.status == LetterStatus.PENDING:
|
| 218 |
+
pending_letters.append(letter)
|
| 219 |
+
|
| 220 |
+
# 作成日時でソート(古い順)
|
| 221 |
+
pending_letters.sort(key=lambda x: x.created_at)
|
| 222 |
+
|
| 223 |
+
return pending_letters
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
self.logger.error(f"処理待ち手紙一覧の取得中にエラーが発生しました: {e}")
|
| 227 |
+
return []
|
| 228 |
+
|
| 229 |
+
async def _update_letter_in_storage(self, letter: Letter) -> None:
|
| 230 |
+
"""内部用:ストレージ内の手紙データを更新する"""
|
| 231 |
+
# 既存データを削除
|
| 232 |
+
await self.storage.delete_letter(letter.id)
|
| 233 |
+
|
| 234 |
+
# 新しいデータを保存
|
| 235 |
+
await self.storage.save_letter(letter.dict())
|
| 236 |
+
|
| 237 |
+
# グローバルマネージャーインスタンス
|
| 238 |
+
manager_instance = None
|
| 239 |
+
|
| 240 |
+
def get_letter_manager() -> LetterManager:
|
| 241 |
+
"""手紙マネージャーインスタンスを取得(シングルトン)"""
|
| 242 |
+
global manager_instance
|
| 243 |
+
|
| 244 |
+
if manager_instance is None:
|
| 245 |
+
manager_instance = LetterManager()
|
| 246 |
+
|
| 247 |
+
return manager_instance
|
letter_models.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
手紙生成アプリのデータモデル定義
|
| 3 |
+
LetterRequest、GeneratedLetter、UserProfileのデータクラスと
|
| 4 |
+
バリデーション機能を提供します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from dataclasses import dataclass, field
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Dict, Any, Optional, List
|
| 10 |
+
import re
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class LetterRequest:
|
| 16 |
+
"""手紙リクエストのデータクラス"""
|
| 17 |
+
user_id: str
|
| 18 |
+
theme: str
|
| 19 |
+
requested_at: datetime
|
| 20 |
+
generation_hour: int # 2, 3, 4のいずれか
|
| 21 |
+
status: str = "pending"
|
| 22 |
+
|
| 23 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 24 |
+
"""辞書形式に変換"""
|
| 25 |
+
return {
|
| 26 |
+
"user_id": self.user_id,
|
| 27 |
+
"theme": self.theme,
|
| 28 |
+
"requested_at": self.requested_at.isoformat(),
|
| 29 |
+
"generation_hour": self.generation_hour,
|
| 30 |
+
"status": self.status
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
@classmethod
|
| 34 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'LetterRequest':
|
| 35 |
+
"""辞書からインスタンスを作成"""
|
| 36 |
+
return cls(
|
| 37 |
+
user_id=data["user_id"],
|
| 38 |
+
theme=data["theme"],
|
| 39 |
+
requested_at=datetime.fromisoformat(data["requested_at"]),
|
| 40 |
+
generation_hour=data["generation_hour"],
|
| 41 |
+
status=data.get("status", "pending")
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass
|
| 46 |
+
class GeneratedLetter:
|
| 47 |
+
"""生成された手紙のデータクラス"""
|
| 48 |
+
user_id: str
|
| 49 |
+
theme: str
|
| 50 |
+
content: str
|
| 51 |
+
generated_at: datetime
|
| 52 |
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
| 53 |
+
|
| 54 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 55 |
+
"""辞書形式に変換"""
|
| 56 |
+
return {
|
| 57 |
+
"user_id": self.user_id,
|
| 58 |
+
"theme": self.theme,
|
| 59 |
+
"content": self.content,
|
| 60 |
+
"generated_at": self.generated_at.isoformat(),
|
| 61 |
+
"metadata": self.metadata
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
@classmethod
|
| 65 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'GeneratedLetter':
|
| 66 |
+
"""辞書からインスタンスを作成"""
|
| 67 |
+
return cls(
|
| 68 |
+
user_id=data["user_id"],
|
| 69 |
+
theme=data["theme"],
|
| 70 |
+
content=data["content"],
|
| 71 |
+
generated_at=datetime.fromisoformat(data["generated_at"]),
|
| 72 |
+
metadata=data.get("metadata", {})
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class UserProfile:
|
| 78 |
+
"""ユーザープロファイルのデータクラス"""
|
| 79 |
+
user_id: str
|
| 80 |
+
created_at: datetime
|
| 81 |
+
last_request: Optional[str] = None
|
| 82 |
+
total_letters: int = 0
|
| 83 |
+
|
| 84 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 85 |
+
"""辞書形式に変換"""
|
| 86 |
+
return {
|
| 87 |
+
"user_id": self.user_id,
|
| 88 |
+
"created_at": self.created_at.isoformat(),
|
| 89 |
+
"last_request": self.last_request,
|
| 90 |
+
"total_letters": self.total_letters
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
@classmethod
|
| 94 |
+
def from_dict(cls, data: Dict[str, Any]) -> 'UserProfile':
|
| 95 |
+
"""辞書からインスタンスを作成"""
|
| 96 |
+
return cls(
|
| 97 |
+
user_id=data["user_id"],
|
| 98 |
+
created_at=datetime.fromisoformat(data["created_at"]),
|
| 99 |
+
last_request=data.get("last_request"),
|
| 100 |
+
total_letters=data.get("total_letters", 0)
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class ValidationError(Exception):
|
| 105 |
+
"""バリデーションエラー"""
|
| 106 |
+
pass
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class ThemeValidator:
|
| 110 |
+
"""テーマのバリデーション機能"""
|
| 111 |
+
|
| 112 |
+
MIN_LENGTH = 1
|
| 113 |
+
MAX_LENGTH = 100
|
| 114 |
+
|
| 115 |
+
# 禁止されている文字パターン
|
| 116 |
+
FORBIDDEN_PATTERNS = [
|
| 117 |
+
r'<[^>]*>', # HTMLタグ
|
| 118 |
+
r'javascript:', # JavaScript
|
| 119 |
+
r'data:', # データURL
|
| 120 |
+
]
|
| 121 |
+
|
| 122 |
+
@classmethod
|
| 123 |
+
def validate(cls, theme: str) -> bool:
|
| 124 |
+
"""テーマの妥当性を検証"""
|
| 125 |
+
if not theme or not isinstance(theme, str):
|
| 126 |
+
raise ValidationError("テーマは文字列である必要があります")
|
| 127 |
+
|
| 128 |
+
# 長さチェック
|
| 129 |
+
theme = theme.strip()
|
| 130 |
+
if len(theme) < cls.MIN_LENGTH:
|
| 131 |
+
raise ValidationError("テーマは1文字以上入力してください")
|
| 132 |
+
|
| 133 |
+
if len(theme) > cls.MAX_LENGTH:
|
| 134 |
+
raise ValidationError(f"テーマは{cls.MAX_LENGTH}文字以内で入力してください")
|
| 135 |
+
|
| 136 |
+
# 禁止パターンチェック
|
| 137 |
+
for pattern in cls.FORBIDDEN_PATTERNS:
|
| 138 |
+
if re.search(pattern, theme, re.IGNORECASE):
|
| 139 |
+
raise ValidationError("不正な文字が含まれています")
|
| 140 |
+
|
| 141 |
+
return True
|
| 142 |
+
|
| 143 |
+
@classmethod
|
| 144 |
+
def sanitize(cls, theme: str) -> str:
|
| 145 |
+
"""テーマをサニタイズ"""
|
| 146 |
+
if not theme:
|
| 147 |
+
return ""
|
| 148 |
+
|
| 149 |
+
# 前後の空白を削除
|
| 150 |
+
theme = theme.strip()
|
| 151 |
+
|
| 152 |
+
# 改行文字を空白に変換
|
| 153 |
+
theme = re.sub(r'\s+', ' ', theme)
|
| 154 |
+
|
| 155 |
+
return theme
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
class GenerationTimeValidator:
|
| 159 |
+
"""生成時刻のバリデーション機能"""
|
| 160 |
+
|
| 161 |
+
VALID_HOURS = [2, 3, 4] # 2時、3時、4時のみ有効
|
| 162 |
+
|
| 163 |
+
@classmethod
|
| 164 |
+
def validate(cls, hour: int) -> bool:
|
| 165 |
+
"""生成時刻の妥当性を検証"""
|
| 166 |
+
if not isinstance(hour, int):
|
| 167 |
+
raise ValidationError("生成時刻は整数である必要があります")
|
| 168 |
+
|
| 169 |
+
if hour not in cls.VALID_HOURS:
|
| 170 |
+
valid_hours_str = "、".join(map(str, cls.VALID_HOURS))
|
| 171 |
+
raise ValidationError(f"生成時刻は{valid_hours_str}時のいずれかを選択してください")
|
| 172 |
+
|
| 173 |
+
return True
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class DataValidator:
|
| 177 |
+
"""データ全般のバリデーション機能"""
|
| 178 |
+
|
| 179 |
+
@staticmethod
|
| 180 |
+
def validate_user_id(user_id: str) -> bool:
|
| 181 |
+
"""ユーザーIDの妥当性を検証"""
|
| 182 |
+
if not user_id or not isinstance(user_id, str):
|
| 183 |
+
raise ValidationError("ユーザーIDは文字列である必要があります")
|
| 184 |
+
|
| 185 |
+
# UUIDv4形式のチェック(簡易版)
|
| 186 |
+
uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
|
| 187 |
+
if not re.match(uuid_pattern, user_id, re.IGNORECASE):
|
| 188 |
+
raise ValidationError("ユーザーIDの形式が正しくありません")
|
| 189 |
+
|
| 190 |
+
return True
|
| 191 |
+
|
| 192 |
+
@staticmethod
|
| 193 |
+
def validate_letter_request(request: LetterRequest) -> bool:
|
| 194 |
+
"""手紙リクエストの妥当性を検証"""
|
| 195 |
+
DataValidator.validate_user_id(request.user_id)
|
| 196 |
+
ThemeValidator.validate(request.theme)
|
| 197 |
+
GenerationTimeValidator.validate(request.generation_hour)
|
| 198 |
+
|
| 199 |
+
# ステータスの検証
|
| 200 |
+
valid_statuses = ["pending", "processing", "completed", "failed"]
|
| 201 |
+
if request.status not in valid_statuses:
|
| 202 |
+
raise ValidationError(f"ステータスは{valid_statuses}のいずれかである必要があります")
|
| 203 |
+
|
| 204 |
+
return True
|
| 205 |
+
|
| 206 |
+
@staticmethod
|
| 207 |
+
def validate_generated_letter(letter: GeneratedLetter) -> bool:
|
| 208 |
+
"""生成された手紙の妥当性を検証"""
|
| 209 |
+
DataValidator.validate_user_id(letter.user_id)
|
| 210 |
+
ThemeValidator.validate(letter.theme)
|
| 211 |
+
|
| 212 |
+
# 手紙内容の検証
|
| 213 |
+
if not letter.content or not isinstance(letter.content, str):
|
| 214 |
+
raise ValidationError("手紙の内容は文字列である必要があります")
|
| 215 |
+
|
| 216 |
+
if len(letter.content.strip()) < 10:
|
| 217 |
+
raise ValidationError("手紙の内容が短すぎます")
|
| 218 |
+
|
| 219 |
+
return True
|
| 220 |
+
|
| 221 |
+
@staticmethod
|
| 222 |
+
def validate_user_profile(profile: UserProfile) -> bool:
|
| 223 |
+
"""ユーザープロファイルの妥当性を検証"""
|
| 224 |
+
DataValidator.validate_user_id(profile.user_id)
|
| 225 |
+
|
| 226 |
+
# 手紙数の検証
|
| 227 |
+
if not isinstance(profile.total_letters, int) or profile.total_letters < 0:
|
| 228 |
+
raise ValidationError("手紙数は0以上の整数である必要があります")
|
| 229 |
+
|
| 230 |
+
return True
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# テスト用のサンプルデータ作成関数
|
| 234 |
+
def create_sample_data():
|
| 235 |
+
"""テスト用のサンプルデータを作成"""
|
| 236 |
+
import uuid
|
| 237 |
+
|
| 238 |
+
user_id = str(uuid.uuid4())
|
| 239 |
+
now = datetime.now()
|
| 240 |
+
|
| 241 |
+
# サンプルリクエスト
|
| 242 |
+
request = LetterRequest(
|
| 243 |
+
user_id=user_id,
|
| 244 |
+
theme="春の思い出",
|
| 245 |
+
requested_at=now,
|
| 246 |
+
generation_hour=2
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# サンプル手紙
|
| 250 |
+
letter = GeneratedLetter(
|
| 251 |
+
user_id=user_id,
|
| 252 |
+
theme="春の思い出",
|
| 253 |
+
content="桜の花びらが舞い散る季節になりました。あなたとの思い出が蘇ります...",
|
| 254 |
+
generated_at=now,
|
| 255 |
+
metadata={
|
| 256 |
+
"groq_model": "compound-beta",
|
| 257 |
+
"Together_model": "Qwen/Qwen3-235B-A22B-Instruct-2507-tput",
|
| 258 |
+
"generation_time": 12.5
|
| 259 |
+
}
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
# サンプルプロファイル
|
| 263 |
+
profile = UserProfile(
|
| 264 |
+
user_id=user_id,
|
| 265 |
+
created_at=now,
|
| 266 |
+
last_request="2024-01-20",
|
| 267 |
+
total_letters=1
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
return request, letter, profile
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
if __name__ == "__main__":
|
| 274 |
+
# テスト実行
|
| 275 |
+
try:
|
| 276 |
+
request, letter, profile = create_sample_data()
|
| 277 |
+
|
| 278 |
+
print("=== バリデーションテスト ===")
|
| 279 |
+
|
| 280 |
+
# リクエストのバリデーション
|
| 281 |
+
DataValidator.validate_letter_request(request)
|
| 282 |
+
print("✓ LetterRequestのバリデーション成功")
|
| 283 |
+
|
| 284 |
+
# 手紙のバリデーション
|
| 285 |
+
DataValidator.validate_generated_letter(letter)
|
| 286 |
+
print("✓ GeneratedLetterのバリデーション成功")
|
| 287 |
+
|
| 288 |
+
# プロファイルのバリデーション
|
| 289 |
+
DataValidator.validate_user_profile(profile)
|
| 290 |
+
print("✓ UserProfileのバリデーション成功")
|
| 291 |
+
|
| 292 |
+
print("\n=== シリアライゼーションテスト ===")
|
| 293 |
+
|
| 294 |
+
# 辞書変換テスト
|
| 295 |
+
request_dict = request.to_dict()
|
| 296 |
+
request_restored = LetterRequest.from_dict(request_dict)
|
| 297 |
+
print("✓ LetterRequestのシリアライゼーション成功")
|
| 298 |
+
|
| 299 |
+
letter_dict = letter.to_dict()
|
| 300 |
+
letter_restored = GeneratedLetter.from_dict(letter_dict)
|
| 301 |
+
print("✓ GeneratedLetterのシリアライゼーション成功")
|
| 302 |
+
|
| 303 |
+
profile_dict = profile.to_dict()
|
| 304 |
+
profile_restored = UserProfile.from_dict(profile_dict)
|
| 305 |
+
print("✓ UserProfileのシリアライゼーション成功")
|
| 306 |
+
|
| 307 |
+
print("\n=== エラーケーステスト ===")
|
| 308 |
+
|
| 309 |
+
# 不正なテーマのテスト
|
| 310 |
+
try:
|
| 311 |
+
ThemeValidator.validate("")
|
| 312 |
+
except ValidationError as e:
|
| 313 |
+
print(f"✓ 空のテーマエラー: {e}")
|
| 314 |
+
|
| 315 |
+
# 不正な生成時刻のテスト
|
| 316 |
+
try:
|
| 317 |
+
GenerationTimeValidator.validate(5)
|
| 318 |
+
except ValidationError as e:
|
| 319 |
+
print(f"✓ 不正な生成時刻エラー: {e}")
|
| 320 |
+
|
| 321 |
+
print("\n全てのテストが完了しました!")
|
| 322 |
+
|
| 323 |
+
except Exception as e:
|
| 324 |
+
print(f"エラーが発生しました: {e}")
|
letter_request_manager.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
リクエスト管理クラス
|
| 3 |
+
テーマと生成時刻を含むリクエスト送信機能と
|
| 4 |
+
時刻別の未処理リクエスト取得機能を提供します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 11 |
+
import logging
|
| 12 |
+
import uuid
|
| 13 |
+
|
| 14 |
+
# ログ設定
|
| 15 |
+
logging.basicConfig(level=logging.INFO)
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RequestError(Exception):
|
| 20 |
+
"""リクエスト関連のエラー"""
|
| 21 |
+
pass
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RequestManager:
|
| 25 |
+
"""リクエスト管理クラス"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, storage_manager, rate_limiter):
|
| 28 |
+
self.storage = storage_manager
|
| 29 |
+
self.rate_limiter = rate_limiter
|
| 30 |
+
|
| 31 |
+
# 設定値
|
| 32 |
+
self.valid_generation_hours = [2, 3, 4] # 2時、3時、4時
|
| 33 |
+
self.max_theme_length = int(os.getenv("MAX_THEME_LENGTH", "200"))
|
| 34 |
+
self.min_theme_length = int(os.getenv("MIN_THEME_LENGTH", "1"))
|
| 35 |
+
|
| 36 |
+
logger.info(f"RequestManager初期化完了 - 有効な生成時刻: {self.valid_generation_hours}")
|
| 37 |
+
|
| 38 |
+
async def submit_request(self, user_id: str, theme: str, generation_hour: int, affection: int = None) -> Tuple[bool, str]:
|
| 39 |
+
"""
|
| 40 |
+
リクエストを送信する
|
| 41 |
+
|
| 42 |
+
Args:
|
| 43 |
+
user_id: ユーザーID
|
| 44 |
+
theme: 手紙のテーマ
|
| 45 |
+
generation_hour: 生成時刻(2, 3, 4のいずれか)
|
| 46 |
+
affection: 現在の好感度(オプション)
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Tuple[bool, str]: (成功フラグ, メッセージ)
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
# 入力バリデーション
|
| 53 |
+
if not self.validate_theme(theme):
|
| 54 |
+
return False, f"テーマは{self.min_theme_length}文字以上{self.max_theme_length}文字以下で入力してください"
|
| 55 |
+
|
| 56 |
+
if not self.validate_generation_hour(generation_hour):
|
| 57 |
+
return False, f"生成時刻は{self.valid_generation_hours}のいずれかを選択してください"
|
| 58 |
+
|
| 59 |
+
# レート制限チェック
|
| 60 |
+
allowed, limit_message = await self.rate_limiter.is_request_allowed(user_id)
|
| 61 |
+
if not allowed:
|
| 62 |
+
return False, limit_message
|
| 63 |
+
|
| 64 |
+
# 既存のリクエストチェック(同日の重複防止)
|
| 65 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 66 |
+
existing_request = await self._get_user_request_for_date(user_id, today)
|
| 67 |
+
|
| 68 |
+
if existing_request:
|
| 69 |
+
return False, "本日は既にリクエストを送信済みです。1日1回までリクエスト可能です。"
|
| 70 |
+
|
| 71 |
+
# リクエストデータの作成
|
| 72 |
+
request_data = {
|
| 73 |
+
"theme": theme.strip(),
|
| 74 |
+
"status": "pending",
|
| 75 |
+
"requested_at": datetime.now().isoformat(),
|
| 76 |
+
"generation_hour": generation_hour,
|
| 77 |
+
"request_id": str(uuid.uuid4()),
|
| 78 |
+
"affection": affection # 好感度情報を追加
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# ユーザーデータの取得と更新
|
| 82 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 83 |
+
user_data["requests"][today] = request_data
|
| 84 |
+
|
| 85 |
+
# ストレージに保存
|
| 86 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 87 |
+
|
| 88 |
+
# レート制限の記録
|
| 89 |
+
await self.rate_limiter.record_request(user_id)
|
| 90 |
+
|
| 91 |
+
logger.info(f"リクエスト送信成功 - ユーザー: {user_id}, テーマ: {theme[:50]}..., 生成時刻: {generation_hour}時")
|
| 92 |
+
|
| 93 |
+
return True, f"リクエストを受け付けました。{generation_hour}時頃に手紙を生成します。"
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"リクエスト送信エラー: {e}")
|
| 97 |
+
return False, f"リクエストの送信中にエラーが発生しました: {str(e)}"
|
| 98 |
+
|
| 99 |
+
async def get_pending_requests_by_hour(self, hour: int) -> List[Dict[str, Any]]:
|
| 100 |
+
"""
|
| 101 |
+
指定時刻の未処理リクエストを取得する
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
hour: 生成時刻(2, 3, 4のいずれか)
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
List[Dict]: 未処理リクエストのリスト
|
| 108 |
+
"""
|
| 109 |
+
try:
|
| 110 |
+
if hour not in self.valid_generation_hours:
|
| 111 |
+
logger.warning(f"無効な生成時刻が指定されました: {hour}")
|
| 112 |
+
return []
|
| 113 |
+
|
| 114 |
+
all_users = await self.storage.get_all_users()
|
| 115 |
+
pending_requests = []
|
| 116 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 117 |
+
|
| 118 |
+
for user_id in all_users:
|
| 119 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 120 |
+
|
| 121 |
+
# 今日のリクエストをチェック
|
| 122 |
+
if today in user_data["requests"]:
|
| 123 |
+
request = user_data["requests"][today]
|
| 124 |
+
|
| 125 |
+
# 指定時刻かつ未処理のリクエストを抽出
|
| 126 |
+
if (request.get("generation_hour") == hour and
|
| 127 |
+
request.get("status") == "pending"):
|
| 128 |
+
|
| 129 |
+
pending_requests.append({
|
| 130 |
+
"user_id": user_id,
|
| 131 |
+
"theme": request["theme"],
|
| 132 |
+
"requested_at": request["requested_at"],
|
| 133 |
+
"generation_hour": request["generation_hour"],
|
| 134 |
+
"request_id": request.get("request_id", ""),
|
| 135 |
+
"date": today
|
| 136 |
+
})
|
| 137 |
+
|
| 138 |
+
logger.info(f"{hour}時の未処理リクエスト数: {len(pending_requests)}")
|
| 139 |
+
return pending_requests
|
| 140 |
+
|
| 141 |
+
except Exception as e:
|
| 142 |
+
logger.error(f"未処理リクエスト取得エラー: {e}")
|
| 143 |
+
return []
|
| 144 |
+
|
| 145 |
+
async def mark_request_processed(self, user_id: str, date: str, status: str = "completed") -> bool:
|
| 146 |
+
"""
|
| 147 |
+
リクエストを処理済みにマークする
|
| 148 |
+
|
| 149 |
+
Args:
|
| 150 |
+
user_id: ユーザーID
|
| 151 |
+
date: 日付(YYYY-MM-DD形式)
|
| 152 |
+
status: 新しいステータス(completed, failed等)
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
bool: 成功フラグ
|
| 156 |
+
"""
|
| 157 |
+
try:
|
| 158 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 159 |
+
|
| 160 |
+
if date not in user_data["requests"]:
|
| 161 |
+
logger.warning(f"指定された日付のリクエストが見つかりません - ユーザー: {user_id}, 日付: {date}")
|
| 162 |
+
return False
|
| 163 |
+
|
| 164 |
+
# ステータスを更新
|
| 165 |
+
user_data["requests"][date]["status"] = status
|
| 166 |
+
user_data["requests"][date]["processed_at"] = datetime.now().isoformat()
|
| 167 |
+
|
| 168 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 169 |
+
|
| 170 |
+
logger.info(f"リクエストを{status}にマークしました - ユーザー: {user_id}, 日付: {date}")
|
| 171 |
+
return True
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"リクエスト処理マークエラー: {e}")
|
| 175 |
+
return False
|
| 176 |
+
|
| 177 |
+
async def mark_request_failed(self, user_id: str, date: str, error_message: str) -> bool:
|
| 178 |
+
"""
|
| 179 |
+
リクエストを失敗にマークする
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
user_id: ユーザーID
|
| 183 |
+
date: 日付(YYYY-MM-DD形式)
|
| 184 |
+
error_message: エラーメッセージ
|
| 185 |
+
|
| 186 |
+
Returns:
|
| 187 |
+
bool: 成功フラグ
|
| 188 |
+
"""
|
| 189 |
+
try:
|
| 190 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 191 |
+
|
| 192 |
+
if date not in user_data["requests"]:
|
| 193 |
+
logger.warning(f"指定された日付のリクエストが見つかりません - ユーザー: {user_id}, 日付: {date}")
|
| 194 |
+
return False
|
| 195 |
+
|
| 196 |
+
# ステータスとエラー情報を更新
|
| 197 |
+
user_data["requests"][date]["status"] = "failed"
|
| 198 |
+
user_data["requests"][date]["processed_at"] = datetime.now().isoformat()
|
| 199 |
+
user_data["requests"][date]["error_message"] = error_message
|
| 200 |
+
|
| 201 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 202 |
+
|
| 203 |
+
logger.error(f"リクエストを失敗にマークしました - ユーザー: {user_id}, 日付: {date}, エラー: {error_message}")
|
| 204 |
+
return True
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logger.error(f"リクエスト失敗マークエラー: {e}")
|
| 208 |
+
return False
|
| 209 |
+
|
| 210 |
+
def validate_theme(self, theme: str) -> bool:
|
| 211 |
+
"""
|
| 212 |
+
テーマのバリデーション
|
| 213 |
+
|
| 214 |
+
Args:
|
| 215 |
+
theme: テーマ文字列
|
| 216 |
+
|
| 217 |
+
Returns:
|
| 218 |
+
bool: バリデーション結果
|
| 219 |
+
"""
|
| 220 |
+
if not theme or not isinstance(theme, str):
|
| 221 |
+
return False
|
| 222 |
+
|
| 223 |
+
theme = theme.strip()
|
| 224 |
+
|
| 225 |
+
# 長さチェック
|
| 226 |
+
if len(theme) < self.min_theme_length or len(theme) > self.max_theme_length:
|
| 227 |
+
return False
|
| 228 |
+
|
| 229 |
+
# 不正な文字のチェック(基本的な制御文字のみ)
|
| 230 |
+
if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in theme):
|
| 231 |
+
return False
|
| 232 |
+
|
| 233 |
+
return True
|
| 234 |
+
|
| 235 |
+
def validate_generation_hour(self, hour: int) -> bool:
|
| 236 |
+
"""
|
| 237 |
+
生成時刻のバリデーション
|
| 238 |
+
|
| 239 |
+
Args:
|
| 240 |
+
hour: 生成時刻
|
| 241 |
+
|
| 242 |
+
Returns:
|
| 243 |
+
bool: バリデーション結果
|
| 244 |
+
"""
|
| 245 |
+
return isinstance(hour, int) and hour in self.valid_generation_hours
|
| 246 |
+
|
| 247 |
+
async def get_user_request_status(self, user_id: str, date: Optional[str] = None) -> Dict[str, Any]:
|
| 248 |
+
"""
|
| 249 |
+
ユーザーのリクエスト状況を取得する
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
user_id: ユーザーID
|
| 253 |
+
date: 日付(指定しない場合は今日)
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
Dict: リクエスト状況
|
| 257 |
+
"""
|
| 258 |
+
try:
|
| 259 |
+
if date is None:
|
| 260 |
+
date = datetime.now().strftime("%Y-%m-%d")
|
| 261 |
+
|
| 262 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 263 |
+
|
| 264 |
+
if date not in user_data["requests"]:
|
| 265 |
+
return {
|
| 266 |
+
"has_request": False,
|
| 267 |
+
"date": date,
|
| 268 |
+
"can_request": True
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
request = user_data["requests"][date]
|
| 272 |
+
|
| 273 |
+
return {
|
| 274 |
+
"has_request": True,
|
| 275 |
+
"date": date,
|
| 276 |
+
"theme": request["theme"],
|
| 277 |
+
"status": request["status"],
|
| 278 |
+
"generation_hour": request["generation_hour"],
|
| 279 |
+
"requested_at": request["requested_at"],
|
| 280 |
+
"processed_at": request.get("processed_at"),
|
| 281 |
+
"error_message": request.get("error_message"),
|
| 282 |
+
"can_request": False # 既にリクエスト済み
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
except Exception as e:
|
| 286 |
+
logger.error(f"リクエスト状況取得エラー: {e}")
|
| 287 |
+
return {"error": str(e)}
|
| 288 |
+
|
| 289 |
+
async def _get_user_request_for_date(self, user_id: str, date: str) -> Optional[Dict[str, Any]]:
|
| 290 |
+
"""
|
| 291 |
+
指定日のユーザーリクエストを取得する(内部使用)
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
user_id: ユーザーID
|
| 295 |
+
date: 日付(YYYY-MM-DD形式)
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
Optional[Dict]: リクエストデータ(存在しない場合はNone)
|
| 299 |
+
"""
|
| 300 |
+
try:
|
| 301 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 302 |
+
return user_data["requests"].get(date)
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.error(f"ユーザーリクエスト取得エラー: {e}")
|
| 305 |
+
return None
|
| 306 |
+
|
| 307 |
+
async def get_all_pending_requests(self) -> Dict[int, List[Dict[str, Any]]]:
|
| 308 |
+
"""
|
| 309 |
+
全ての未処理リクエストを時刻別に取得する
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
Dict[int, List]: 時刻別の未処理リクエスト
|
| 313 |
+
"""
|
| 314 |
+
try:
|
| 315 |
+
all_pending = {}
|
| 316 |
+
|
| 317 |
+
for hour in self.valid_generation_hours:
|
| 318 |
+
pending_requests = await self.get_pending_requests_by_hour(hour)
|
| 319 |
+
all_pending[hour] = pending_requests
|
| 320 |
+
|
| 321 |
+
return all_pending
|
| 322 |
+
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logger.error(f"全未処理リクエスト取得エラー: {e}")
|
| 325 |
+
return {}
|
| 326 |
+
|
| 327 |
+
async def cleanup_old_requests(self, days: int = 30) -> int:
|
| 328 |
+
"""
|
| 329 |
+
古いリクエストデータを削除する
|
| 330 |
+
|
| 331 |
+
Args:
|
| 332 |
+
days: 保持日数
|
| 333 |
+
|
| 334 |
+
Returns:
|
| 335 |
+
int: 削除されたリクエスト数
|
| 336 |
+
"""
|
| 337 |
+
try:
|
| 338 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 339 |
+
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
|
| 340 |
+
|
| 341 |
+
all_users = await self.storage.get_all_users()
|
| 342 |
+
deleted_count = 0
|
| 343 |
+
|
| 344 |
+
for user_id in all_users:
|
| 345 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 346 |
+
|
| 347 |
+
# 古いリクエストを削除
|
| 348 |
+
requests_to_delete = []
|
| 349 |
+
for date_str in user_data["requests"]:
|
| 350 |
+
if date_str < cutoff_str:
|
| 351 |
+
requests_to_delete.append(date_str)
|
| 352 |
+
|
| 353 |
+
for date_str in requests_to_delete:
|
| 354 |
+
del user_data["requests"][date_str]
|
| 355 |
+
deleted_count += 1
|
| 356 |
+
|
| 357 |
+
if requests_to_delete:
|
| 358 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 359 |
+
|
| 360 |
+
if deleted_count > 0:
|
| 361 |
+
logger.info(f"{deleted_count}件の古いリクエストを削除しました")
|
| 362 |
+
|
| 363 |
+
return deleted_count
|
| 364 |
+
|
| 365 |
+
except Exception as e:
|
| 366 |
+
logger.error(f"古いリクエスト削除エラー: {e}")
|
| 367 |
+
return 0
|
| 368 |
+
|
| 369 |
+
async def get_request_statistics(self) -> Dict[str, Any]:
|
| 370 |
+
"""
|
| 371 |
+
リクエストの統計情報を取得する
|
| 372 |
+
|
| 373 |
+
Returns:
|
| 374 |
+
Dict: 統計情報
|
| 375 |
+
"""
|
| 376 |
+
try:
|
| 377 |
+
all_users = await self.storage.get_all_users()
|
| 378 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 379 |
+
|
| 380 |
+
stats = {
|
| 381 |
+
"total_users": len(all_users),
|
| 382 |
+
"today_requests": 0,
|
| 383 |
+
"pending_requests": 0,
|
| 384 |
+
"completed_requests": 0,
|
| 385 |
+
"failed_requests": 0,
|
| 386 |
+
"requests_by_hour": {hour: 0 for hour in self.valid_generation_hours},
|
| 387 |
+
"date": today
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
for user_id in all_users:
|
| 391 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 392 |
+
|
| 393 |
+
# 今日のリクエストをカウント
|
| 394 |
+
if today in user_data["requests"]:
|
| 395 |
+
request = user_data["requests"][today]
|
| 396 |
+
stats["today_requests"] += 1
|
| 397 |
+
|
| 398 |
+
# ステータス別カウント
|
| 399 |
+
status = request.get("status", "unknown")
|
| 400 |
+
if status == "pending":
|
| 401 |
+
stats["pending_requests"] += 1
|
| 402 |
+
elif status == "completed":
|
| 403 |
+
stats["completed_requests"] += 1
|
| 404 |
+
elif status == "failed":
|
| 405 |
+
stats["failed_requests"] += 1
|
| 406 |
+
|
| 407 |
+
# 時刻別カウント
|
| 408 |
+
hour = request.get("generation_hour")
|
| 409 |
+
if hour in stats["requests_by_hour"]:
|
| 410 |
+
stats["requests_by_hour"][hour] += 1
|
| 411 |
+
|
| 412 |
+
return stats
|
| 413 |
+
|
| 414 |
+
except Exception as e:
|
| 415 |
+
logger.error(f"統計情報取得エラー: {e}")
|
| 416 |
+
return {"error": str(e)}
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
# テスト用の関数
|
| 420 |
+
async def test_request_manager():
|
| 421 |
+
"""RequestManagerのテスト"""
|
| 422 |
+
import tempfile
|
| 423 |
+
from async_storage_manager import AsyncStorageManager
|
| 424 |
+
from async_rate_limiter import AsyncRateLimitManager
|
| 425 |
+
|
| 426 |
+
# 一時ディレクトリでテスト
|
| 427 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 428 |
+
test_file = os.path.join(temp_dir, "test_letters.json")
|
| 429 |
+
storage = AsyncStorageManager(test_file)
|
| 430 |
+
rate_limiter = AsyncRateLimitManager(storage)
|
| 431 |
+
request_manager = RequestManager(storage, rate_limiter)
|
| 432 |
+
|
| 433 |
+
print("=== RequestManagerテスト開始 ===")
|
| 434 |
+
|
| 435 |
+
user_id = str(uuid.uuid4())
|
| 436 |
+
|
| 437 |
+
# リクエスト送信テスト
|
| 438 |
+
success, message = await request_manager.submit_request(user_id, "春の思い出", 2)
|
| 439 |
+
print(f"✓ リクエスト送信テスト: {success} - {message}")
|
| 440 |
+
|
| 441 |
+
# 未処理リクエスト取得テスト
|
| 442 |
+
pending = await request_manager.get_pending_requests_by_hour(2)
|
| 443 |
+
print(f"✓ 未処理リクエスト取得テスト: {len(pending)}件")
|
| 444 |
+
|
| 445 |
+
# リクエスト状況確認テスト
|
| 446 |
+
status = await request_manager.get_user_request_status(user_id)
|
| 447 |
+
print(f"✓ リクエスト状況確認テスト: {status}")
|
| 448 |
+
|
| 449 |
+
# リクエスト処理マークテスト
|
| 450 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 451 |
+
marked = await request_manager.mark_request_processed(user_id, today)
|
| 452 |
+
print(f"✓ リクエスト処理マークテスト: {marked}")
|
| 453 |
+
|
| 454 |
+
# 統計情報取得テスト
|
| 455 |
+
stats = await request_manager.get_request_statistics()
|
| 456 |
+
print(f"✓ 統計情報取得テスト: {stats}")
|
| 457 |
+
|
| 458 |
+
print("=== 全てのテストが完了しました! ===")
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
if __name__ == "__main__":
|
| 462 |
+
asyncio.run(test_request_manager())
|
letter_storage.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ストレージ管理モジュール
|
| 3 |
+
Storage management module
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import asyncio
|
| 8 |
+
import aiofiles
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Dict, List, Optional, Any
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from letter_config import Config
|
| 13 |
+
from letter_logger import get_storage_logger
|
| 14 |
+
|
| 15 |
+
logger = get_storage_logger()
|
| 16 |
+
|
| 17 |
+
class LetterStorage:
|
| 18 |
+
"""手紙データのストレージ管理クラス"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.config = Config()
|
| 22 |
+
self.storage_path = Path(self.config.STORAGE_PATH)
|
| 23 |
+
self.backup_path = Path(self.config.BACKUP_PATH)
|
| 24 |
+
self.logger = logger
|
| 25 |
+
|
| 26 |
+
async def save_letter(self, letter_data: Dict[str, Any]) -> bool:
|
| 27 |
+
"""
|
| 28 |
+
手紙データを保存する
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
letter_data: 保存する手紙データ
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
保存が成功したかどうか
|
| 35 |
+
"""
|
| 36 |
+
try:
|
| 37 |
+
# タイムスタンプを追加
|
| 38 |
+
letter_data['saved_at'] = datetime.now().isoformat()
|
| 39 |
+
|
| 40 |
+
# 既存データを読み込み
|
| 41 |
+
existing_data = await self._load_data()
|
| 42 |
+
|
| 43 |
+
# 新しいデータを追加
|
| 44 |
+
if 'letters' not in existing_data:
|
| 45 |
+
existing_data['letters'] = []
|
| 46 |
+
|
| 47 |
+
existing_data['letters'].append(letter_data)
|
| 48 |
+
|
| 49 |
+
# データを保存
|
| 50 |
+
await self._save_data(existing_data)
|
| 51 |
+
|
| 52 |
+
self.logger.info(f"手紙データを保存しました: {letter_data.get('id', 'unknown')}")
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
self.logger.error(f"手紙データの保存中にエラーが発生しました: {e}")
|
| 57 |
+
return False
|
| 58 |
+
|
| 59 |
+
async def load_letters(self) -> List[Dict[str, Any]]:
|
| 60 |
+
"""
|
| 61 |
+
保存された手紙データを読み込む
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
手紙データのリスト
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
data = await self._load_data()
|
| 68 |
+
return data.get('letters', [])
|
| 69 |
+
|
| 70 |
+
except Exception as e:
|
| 71 |
+
self.logger.error(f"手紙データの読み込み中にエラーが発生しました: {e}")
|
| 72 |
+
return []
|
| 73 |
+
|
| 74 |
+
async def get_letter_by_id(self, letter_id: str) -> Optional[Dict[str, Any]]:
|
| 75 |
+
"""
|
| 76 |
+
IDで手紙データを取得する
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
letter_id: 手紙のID
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
手紙データ(見つからない場合はNone)
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
letters = await self.load_letters()
|
| 86 |
+
for letter in letters:
|
| 87 |
+
if letter.get('id') == letter_id:
|
| 88 |
+
return letter
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
except Exception as e:
|
| 92 |
+
self.logger.error(f"手紙データの取得中にエラーが発生しました: {e}")
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
async def delete_letter(self, letter_id: str) -> bool:
|
| 96 |
+
"""
|
| 97 |
+
手紙データを削除する
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
letter_id: 削除する手紙のID
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
削除が成功したかどうか
|
| 104 |
+
"""
|
| 105 |
+
try:
|
| 106 |
+
data = await self._load_data()
|
| 107 |
+
letters = data.get('letters', [])
|
| 108 |
+
|
| 109 |
+
# 指定されたIDの手紙を削除
|
| 110 |
+
original_count = len(letters)
|
| 111 |
+
letters = [letter for letter in letters if letter.get('id') != letter_id]
|
| 112 |
+
|
| 113 |
+
if len(letters) < original_count:
|
| 114 |
+
data['letters'] = letters
|
| 115 |
+
await self._save_data(data)
|
| 116 |
+
self.logger.info(f"手紙データを削除しました: {letter_id}")
|
| 117 |
+
return True
|
| 118 |
+
else:
|
| 119 |
+
self.logger.warning(f"削除対象の手紙が見つかりませんでした: {letter_id}")
|
| 120 |
+
return False
|
| 121 |
+
|
| 122 |
+
except Exception as e:
|
| 123 |
+
self.logger.error(f"手紙データの削除中にエラーが発生しました: {e}")
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
async def backup_data(self) -> bool:
|
| 127 |
+
"""
|
| 128 |
+
データをバックアップする
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
バックアップが成功したかどうか
|
| 132 |
+
"""
|
| 133 |
+
try:
|
| 134 |
+
if not self.storage_path.exists():
|
| 135 |
+
self.logger.warning("バックアップ対象のファイルが存在しません")
|
| 136 |
+
return False
|
| 137 |
+
|
| 138 |
+
# バックアップディレクトリを作成
|
| 139 |
+
self.backup_path.mkdir(parents=True, exist_ok=True)
|
| 140 |
+
|
| 141 |
+
# タイムスタンプ付きのバックアップファイル名
|
| 142 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 143 |
+
backup_file = self.backup_path / f"letters_backup_{timestamp}.json"
|
| 144 |
+
|
| 145 |
+
# ファイルをコピー
|
| 146 |
+
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as src:
|
| 147 |
+
content = await src.read()
|
| 148 |
+
async with aiofiles.open(backup_file, 'w', encoding='utf-8') as dst:
|
| 149 |
+
await dst.write(content)
|
| 150 |
+
|
| 151 |
+
self.logger.info(f"データをバックアップしました: {backup_file}")
|
| 152 |
+
return True
|
| 153 |
+
|
| 154 |
+
except Exception as e:
|
| 155 |
+
self.logger.error(f"データのバックアップ中にエラーが発生しました: {e}")
|
| 156 |
+
return False
|
| 157 |
+
|
| 158 |
+
async def _load_data(self) -> Dict[str, Any]:
|
| 159 |
+
"""内部用:データファイルを読み込む"""
|
| 160 |
+
try:
|
| 161 |
+
if not self.storage_path.exists():
|
| 162 |
+
return {}
|
| 163 |
+
|
| 164 |
+
async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
|
| 165 |
+
content = await f.read()
|
| 166 |
+
return json.loads(content) if content.strip() else {}
|
| 167 |
+
|
| 168 |
+
except Exception as e:
|
| 169 |
+
self.logger.error(f"データファイルの読み込み中にエラーが発生しました: {e}")
|
| 170 |
+
return {}
|
| 171 |
+
|
| 172 |
+
async def _save_data(self, data: Dict[str, Any]) -> None:
|
| 173 |
+
"""内部用:データファイルに保存する"""
|
| 174 |
+
# ディレクトリを作成
|
| 175 |
+
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
| 176 |
+
|
| 177 |
+
async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
|
| 178 |
+
await f.write(json.dumps(data, ensure_ascii=False, indent=2))
|
| 179 |
+
|
| 180 |
+
# グローバルストレージインスタンス
|
| 181 |
+
storage_instance = None
|
| 182 |
+
|
| 183 |
+
def get_storage() -> LetterStorage:
|
| 184 |
+
"""ストレージインスタンスを取得(シングルトン)"""
|
| 185 |
+
global storage_instance
|
| 186 |
+
|
| 187 |
+
if storage_instance is None:
|
| 188 |
+
storage_instance = LetterStorage()
|
| 189 |
+
|
| 190 |
+
return storage_instance
|
letter_user_manager.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ユーザー管理クラス
|
| 3 |
+
ユーザープロファイル管理機能と
|
| 4 |
+
ユーザー履歴の更新・取得機能を提供します。
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Dict, Any, List, Optional, Tuple
|
| 11 |
+
import logging
|
| 12 |
+
import uuid
|
| 13 |
+
import hashlib
|
| 14 |
+
import secrets
|
| 15 |
+
|
| 16 |
+
# ログ設定
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class UserError(Exception):
|
| 22 |
+
"""ユーザー関連のエラー"""
|
| 23 |
+
pass
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class UserManager:
|
| 27 |
+
"""ユーザー管理クラス"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, storage_manager):
|
| 30 |
+
self.storage = storage_manager
|
| 31 |
+
|
| 32 |
+
# 設定値
|
| 33 |
+
self.session_timeout_hours = int(os.getenv("SESSION_TIMEOUT_HOURS", "24"))
|
| 34 |
+
self.max_history_entries = int(os.getenv("MAX_HISTORY_ENTRIES", "100"))
|
| 35 |
+
self.user_data_retention_days = int(os.getenv("USER_DATA_RETENTION_DAYS", "90"))
|
| 36 |
+
|
| 37 |
+
logger.info(f"UserManager初期化完了 - セッションタイムアウト: {self.session_timeout_hours}時間")
|
| 38 |
+
|
| 39 |
+
def generate_user_id(self) -> str:
|
| 40 |
+
"""
|
| 41 |
+
新しいユーザーIDを生成する
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
str: 生成されたユーザーID(UUID4形式)
|
| 45 |
+
"""
|
| 46 |
+
return str(uuid.uuid4())
|
| 47 |
+
|
| 48 |
+
def generate_session_id(self) -> str:
|
| 49 |
+
"""
|
| 50 |
+
新しいセッションIDを生成する
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
str: 生成されたセッションID
|
| 54 |
+
"""
|
| 55 |
+
return secrets.token_urlsafe(32)
|
| 56 |
+
|
| 57 |
+
async def get_user_profile(self, user_id: str) -> Dict[str, Any]:
|
| 58 |
+
"""
|
| 59 |
+
ユーザープロファイルを取得する
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
user_id: ユーザーID
|
| 63 |
+
|
| 64 |
+
Returns:
|
| 65 |
+
Dict: ユーザープロファイル
|
| 66 |
+
"""
|
| 67 |
+
try:
|
| 68 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 69 |
+
profile = user_data["profile"].copy()
|
| 70 |
+
|
| 71 |
+
# 追加の統計情報を計算
|
| 72 |
+
profile["total_requests"] = len(user_data["requests"])
|
| 73 |
+
profile["completed_letters"] = len([
|
| 74 |
+
letter for letter in user_data["letters"].values()
|
| 75 |
+
if letter.get("status") == "completed"
|
| 76 |
+
])
|
| 77 |
+
profile["pending_requests"] = len([
|
| 78 |
+
request for request in user_data["requests"].values()
|
| 79 |
+
if request.get("status") == "pending"
|
| 80 |
+
])
|
| 81 |
+
|
| 82 |
+
# 最後のアクティビティ時刻を計算
|
| 83 |
+
last_activity = self._calculate_last_activity(user_data)
|
| 84 |
+
if last_activity:
|
| 85 |
+
profile["last_activity"] = last_activity
|
| 86 |
+
|
| 87 |
+
return profile
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"ユーザープロファイル取得エラー: {e}")
|
| 91 |
+
raise UserError(f"ユーザープロファイルの取得に失敗しました: {e}")
|
| 92 |
+
|
| 93 |
+
async def update_user_profile(self, user_id: str, profile_updates: Dict[str, Any]) -> bool:
|
| 94 |
+
"""
|
| 95 |
+
ユーザープロファイルを更新する
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
user_id: ユーザーID
|
| 99 |
+
profile_updates: 更新するプロファイル情報
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
bool: 更新成功フラグ
|
| 103 |
+
"""
|
| 104 |
+
try:
|
| 105 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 106 |
+
|
| 107 |
+
# 更新可能なフィールドのみを許可
|
| 108 |
+
allowed_fields = {
|
| 109 |
+
"display_name", "preferences", "timezone", "language",
|
| 110 |
+
"notification_settings", "theme_preferences"
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
for key, value in profile_updates.items():
|
| 114 |
+
if key in allowed_fields:
|
| 115 |
+
user_data["profile"][key] = value
|
| 116 |
+
|
| 117 |
+
# 更新時刻を記録
|
| 118 |
+
user_data["profile"]["updated_at"] = datetime.now().isoformat()
|
| 119 |
+
|
| 120 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 121 |
+
|
| 122 |
+
logger.info(f"ユーザープロファイルを更新しました: {user_id}")
|
| 123 |
+
return True
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"ユーザープロファイル更新エラー: {e}")
|
| 127 |
+
return False
|
| 128 |
+
|
| 129 |
+
async def update_user_history(self, user_id: str, interaction: Dict[str, Any]) -> bool:
|
| 130 |
+
"""
|
| 131 |
+
ユーザー履歴を更新する
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
user_id: ユーザーID
|
| 135 |
+
interaction: インタラクション情報
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
bool: 更新成功フラグ
|
| 139 |
+
"""
|
| 140 |
+
try:
|
| 141 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 142 |
+
|
| 143 |
+
# 履歴エントリの作成
|
| 144 |
+
history_entry = {
|
| 145 |
+
"timestamp": datetime.now().isoformat(),
|
| 146 |
+
"type": interaction.get("type", "unknown"),
|
| 147 |
+
"action": interaction.get("action", ""),
|
| 148 |
+
"details": interaction.get("details", {}),
|
| 149 |
+
"session_id": interaction.get("session_id", ""),
|
| 150 |
+
"entry_id": str(uuid.uuid4())
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
# 履歴配列の初期化(存在しない場合)
|
| 154 |
+
if "history" not in user_data:
|
| 155 |
+
user_data["history"] = []
|
| 156 |
+
|
| 157 |
+
# 履歴エントリを追加
|
| 158 |
+
user_data["history"].append(history_entry)
|
| 159 |
+
|
| 160 |
+
# 履歴の上限チェックと古いエントリの削除
|
| 161 |
+
if len(user_data["history"]) > self.max_history_entries:
|
| 162 |
+
# 古いエントリから削除
|
| 163 |
+
user_data["history"] = user_data["history"][-self.max_history_entries:]
|
| 164 |
+
|
| 165 |
+
# プロファイルの統計情報を更新
|
| 166 |
+
await self._update_profile_stats(user_data, interaction)
|
| 167 |
+
|
| 168 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 169 |
+
|
| 170 |
+
logger.info(f"ユーザー履歴を更新しました: {user_id} - {interaction.get('type', 'unknown')}")
|
| 171 |
+
return True
|
| 172 |
+
|
| 173 |
+
except Exception as e:
|
| 174 |
+
logger.error(f"ユーザー履歴更新エラー: {e}")
|
| 175 |
+
return False
|
| 176 |
+
|
| 177 |
+
async def get_user_letter_history(self, user_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
| 178 |
+
"""
|
| 179 |
+
ユーザーの手紙履歴を取得する
|
| 180 |
+
|
| 181 |
+
Args:
|
| 182 |
+
user_id: ユーザーID
|
| 183 |
+
limit: 取得件数の上限
|
| 184 |
+
|
| 185 |
+
Returns:
|
| 186 |
+
List[Dict]: 手紙履歴のリスト
|
| 187 |
+
"""
|
| 188 |
+
try:
|
| 189 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 190 |
+
letters = user_data["letters"]
|
| 191 |
+
|
| 192 |
+
# 手紙データを日付順(新しい順)でソート
|
| 193 |
+
sorted_letters = []
|
| 194 |
+
for date, letter_data in letters.items():
|
| 195 |
+
letter_info = {
|
| 196 |
+
"date": date,
|
| 197 |
+
"theme": letter_data.get("theme", ""),
|
| 198 |
+
"status": letter_data.get("status", "unknown"),
|
| 199 |
+
"generated_at": letter_data.get("generated_at"),
|
| 200 |
+
"content_length": len(letter_data.get("content", "")),
|
| 201 |
+
"metadata": letter_data.get("metadata", {})
|
| 202 |
+
}
|
| 203 |
+
sorted_letters.append(letter_info)
|
| 204 |
+
|
| 205 |
+
# 日付順でソート(新しい順)
|
| 206 |
+
sorted_letters.sort(key=lambda x: x["date"], reverse=True)
|
| 207 |
+
|
| 208 |
+
# 上限が指定されている場合は制限
|
| 209 |
+
if limit:
|
| 210 |
+
sorted_letters = sorted_letters[:limit]
|
| 211 |
+
|
| 212 |
+
return sorted_letters
|
| 213 |
+
|
| 214 |
+
except Exception as e:
|
| 215 |
+
logger.error(f"手紙履歴取得エラー: {e}")
|
| 216 |
+
return []
|
| 217 |
+
|
| 218 |
+
async def get_user_interaction_history(self, user_id: str,
|
| 219 |
+
interaction_type: Optional[str] = None,
|
| 220 |
+
limit: Optional[int] = None) -> List[Dict[str, Any]]:
|
| 221 |
+
"""
|
| 222 |
+
ユーザーのインタラクション履歴を取得する
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
user_id: ユーザーID
|
| 226 |
+
interaction_type: フィルタするインタラクションタイプ
|
| 227 |
+
limit: 取得件数の上限
|
| 228 |
+
|
| 229 |
+
Returns:
|
| 230 |
+
List[Dict]: インタラクション履歴のリスト
|
| 231 |
+
"""
|
| 232 |
+
try:
|
| 233 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 234 |
+
history = user_data.get("history", [])
|
| 235 |
+
|
| 236 |
+
# タイプでフィルタ
|
| 237 |
+
if interaction_type:
|
| 238 |
+
history = [entry for entry in history if entry.get("type") == interaction_type]
|
| 239 |
+
|
| 240 |
+
# 時刻順でソート(新しい順)
|
| 241 |
+
history.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
| 242 |
+
|
| 243 |
+
# 上限が指定されている場合は制限
|
| 244 |
+
if limit:
|
| 245 |
+
history = history[:limit]
|
| 246 |
+
|
| 247 |
+
return history
|
| 248 |
+
|
| 249 |
+
except Exception as e:
|
| 250 |
+
logger.error(f"インタラクション履歴取得エラー: {e}")
|
| 251 |
+
return []
|
| 252 |
+
|
| 253 |
+
async def create_user_session(self, user_id: str, session_info: Dict[str, Any]) -> str:
|
| 254 |
+
"""
|
| 255 |
+
ユーザーセッションを作成する
|
| 256 |
+
|
| 257 |
+
Args:
|
| 258 |
+
user_id: ユーザーID
|
| 259 |
+
session_info: セッション情報
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
str: セッションID
|
| 263 |
+
"""
|
| 264 |
+
try:
|
| 265 |
+
session_id = self.generate_session_id()
|
| 266 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 267 |
+
|
| 268 |
+
# セッション情報の作成
|
| 269 |
+
session_data = {
|
| 270 |
+
"session_id": session_id,
|
| 271 |
+
"created_at": datetime.now().isoformat(),
|
| 272 |
+
"expires_at": (datetime.now() + timedelta(hours=self.session_timeout_hours)).isoformat(),
|
| 273 |
+
"ip_address": session_info.get("ip_address", ""),
|
| 274 |
+
"user_agent": session_info.get("user_agent", ""),
|
| 275 |
+
"is_active": True
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
# セッション配列の初期化(存在しない場合)
|
| 279 |
+
if "sessions" not in user_data:
|
| 280 |
+
user_data["sessions"] = []
|
| 281 |
+
|
| 282 |
+
# 古いセッションを無効化
|
| 283 |
+
await self._cleanup_expired_sessions(user_data)
|
| 284 |
+
|
| 285 |
+
# 新しいセッションを追加
|
| 286 |
+
user_data["sessions"].append(session_data)
|
| 287 |
+
|
| 288 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 289 |
+
|
| 290 |
+
logger.info(f"ユーザーセッションを作成しました: {user_id} - {session_id}")
|
| 291 |
+
return session_id
|
| 292 |
+
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.error(f"セッション作成エラー: {e}")
|
| 295 |
+
raise UserError(f"セッションの作成に失敗しました: {e}")
|
| 296 |
+
|
| 297 |
+
async def validate_user_session(self, user_id: str, session_id: str) -> bool:
|
| 298 |
+
"""
|
| 299 |
+
ユーザーセッションを検証する
|
| 300 |
+
|
| 301 |
+
Args:
|
| 302 |
+
user_id: ユーザーID
|
| 303 |
+
session_id: セッションID
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
bool: セッション有効フラグ
|
| 307 |
+
"""
|
| 308 |
+
try:
|
| 309 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 310 |
+
sessions = user_data.get("sessions", [])
|
| 311 |
+
|
| 312 |
+
for session in sessions:
|
| 313 |
+
if (session.get("session_id") == session_id and
|
| 314 |
+
session.get("is_active", False)):
|
| 315 |
+
|
| 316 |
+
# 有効期限チェック
|
| 317 |
+
expires_at = datetime.fromisoformat(session["expires_at"])
|
| 318 |
+
if datetime.now() < expires_at:
|
| 319 |
+
return True
|
| 320 |
+
else:
|
| 321 |
+
# 期限切れセッションを無効化
|
| 322 |
+
session["is_active"] = False
|
| 323 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 324 |
+
|
| 325 |
+
return False
|
| 326 |
+
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"セッション検証エラー: {e}")
|
| 329 |
+
return False
|
| 330 |
+
|
| 331 |
+
async def invalidate_user_session(self, user_id: str, session_id: str) -> bool:
|
| 332 |
+
"""
|
| 333 |
+
ユーザーセッションを無効化する
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
user_id: ユーザーID
|
| 337 |
+
session_id: セッションID
|
| 338 |
+
|
| 339 |
+
Returns:
|
| 340 |
+
bool: 無効化成功フラグ
|
| 341 |
+
"""
|
| 342 |
+
try:
|
| 343 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 344 |
+
sessions = user_data.get("sessions", [])
|
| 345 |
+
|
| 346 |
+
for session in sessions:
|
| 347 |
+
if session.get("session_id") == session_id:
|
| 348 |
+
session["is_active"] = False
|
| 349 |
+
session["invalidated_at"] = datetime.now().isoformat()
|
| 350 |
+
|
| 351 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 352 |
+
|
| 353 |
+
logger.info(f"セッションを無効化しました: {user_id} - {session_id}")
|
| 354 |
+
return True
|
| 355 |
+
|
| 356 |
+
return False
|
| 357 |
+
|
| 358 |
+
except Exception as e:
|
| 359 |
+
logger.error(f"セッション無効化エラー: {e}")
|
| 360 |
+
return False
|
| 361 |
+
|
| 362 |
+
async def get_user_preferences(self, user_id: str) -> Dict[str, Any]:
|
| 363 |
+
"""
|
| 364 |
+
ユーザーの設定を取得する
|
| 365 |
+
|
| 366 |
+
Args:
|
| 367 |
+
user_id: ユーザーID
|
| 368 |
+
|
| 369 |
+
Returns:
|
| 370 |
+
Dict: ユーザー設定
|
| 371 |
+
"""
|
| 372 |
+
try:
|
| 373 |
+
profile = await self.get_user_profile(user_id)
|
| 374 |
+
|
| 375 |
+
# デフォルト設定
|
| 376 |
+
default_preferences = {
|
| 377 |
+
"theme": "light",
|
| 378 |
+
"language": "ja",
|
| 379 |
+
"timezone": "Asia/Tokyo",
|
| 380 |
+
"notification_enabled": True,
|
| 381 |
+
"generation_time_preference": 2, # デフォルトは2時
|
| 382 |
+
"theme_suggestions": True,
|
| 383 |
+
"history_retention": True
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
# ユーザー設定をマージ
|
| 387 |
+
preferences = default_preferences.copy()
|
| 388 |
+
if "preferences" in profile:
|
| 389 |
+
preferences.update(profile["preferences"])
|
| 390 |
+
|
| 391 |
+
return preferences
|
| 392 |
+
|
| 393 |
+
except Exception as e:
|
| 394 |
+
logger.error(f"ユーザー設定取得エラー: {e}")
|
| 395 |
+
return {}
|
| 396 |
+
|
| 397 |
+
async def update_user_preferences(self, user_id: str, preferences: Dict[str, Any]) -> bool:
|
| 398 |
+
"""
|
| 399 |
+
ユーザーの設定を更新する
|
| 400 |
+
|
| 401 |
+
Args:
|
| 402 |
+
user_id: ユーザーID
|
| 403 |
+
preferences: 更新する設定
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
bool: 更新成功フラグ
|
| 407 |
+
"""
|
| 408 |
+
try:
|
| 409 |
+
current_preferences = await self.get_user_preferences(user_id)
|
| 410 |
+
current_preferences.update(preferences)
|
| 411 |
+
|
| 412 |
+
return await self.update_user_profile(user_id, {"preferences": current_preferences})
|
| 413 |
+
|
| 414 |
+
except Exception as e:
|
| 415 |
+
logger.error(f"ユーザー設定更新エラー: {e}")
|
| 416 |
+
return False
|
| 417 |
+
|
| 418 |
+
async def _update_profile_stats(self, user_data: Dict[str, Any], interaction: Dict[str, Any]) -> None:
|
| 419 |
+
"""
|
| 420 |
+
プロファイルの統計情報を更新する(内部使用)
|
| 421 |
+
|
| 422 |
+
Args:
|
| 423 |
+
user_data: ユーザーデータ
|
| 424 |
+
interaction: インタラクション情報
|
| 425 |
+
"""
|
| 426 |
+
try:
|
| 427 |
+
profile = user_data["profile"]
|
| 428 |
+
interaction_type = interaction.get("type", "")
|
| 429 |
+
|
| 430 |
+
# インタラクションタイプ別の統計更新
|
| 431 |
+
if interaction_type == "letter_request":
|
| 432 |
+
profile["total_letters"] = profile.get("total_letters", 0) + 1
|
| 433 |
+
elif interaction_type == "letter_generated":
|
| 434 |
+
# 生成完了時の統計更新は別途処理
|
| 435 |
+
pass
|
| 436 |
+
elif interaction_type == "app_access":
|
| 437 |
+
profile["last_access"] = datetime.now().isoformat()
|
| 438 |
+
|
| 439 |
+
# 最終アクティビティ時刻を更新
|
| 440 |
+
profile["last_activity"] = datetime.now().isoformat()
|
| 441 |
+
|
| 442 |
+
except Exception as e:
|
| 443 |
+
logger.error(f"プロファイル統計更新エラー: {e}")
|
| 444 |
+
|
| 445 |
+
def _calculate_last_activity(self, user_data: Dict[str, Any]) -> Optional[str]:
|
| 446 |
+
"""
|
| 447 |
+
最後のアクティビティ時刻を計算する(内部使用)
|
| 448 |
+
|
| 449 |
+
Args:
|
| 450 |
+
user_data: ユーザーデータ
|
| 451 |
+
|
| 452 |
+
Returns:
|
| 453 |
+
Optional[str]: 最後のアクティビティ時刻
|
| 454 |
+
"""
|
| 455 |
+
try:
|
| 456 |
+
timestamps = []
|
| 457 |
+
|
| 458 |
+
# プロファイルの最終アクセス時刻
|
| 459 |
+
if "last_access" in user_data["profile"]:
|
| 460 |
+
timestamps.append(user_data["profile"]["last_access"])
|
| 461 |
+
|
| 462 |
+
# 履歴の最新エントリ
|
| 463 |
+
history = user_data.get("history", [])
|
| 464 |
+
if history:
|
| 465 |
+
latest_history = max(history, key=lambda x: x.get("timestamp", ""))
|
| 466 |
+
timestamps.append(latest_history["timestamp"])
|
| 467 |
+
|
| 468 |
+
# リクエストの最新エントリ
|
| 469 |
+
requests = user_data.get("requests", {})
|
| 470 |
+
if requests:
|
| 471 |
+
latest_request = max(requests.values(), key=lambda x: x.get("requested_at", ""))
|
| 472 |
+
timestamps.append(latest_request["requested_at"])
|
| 473 |
+
|
| 474 |
+
if timestamps:
|
| 475 |
+
return max(timestamps)
|
| 476 |
+
|
| 477 |
+
return None
|
| 478 |
+
|
| 479 |
+
except Exception as e:
|
| 480 |
+
logger.error(f"最終アクティビティ計算エラー: {e}")
|
| 481 |
+
return None
|
| 482 |
+
|
| 483 |
+
async def _cleanup_expired_sessions(self, user_data: Dict[str, Any]) -> None:
|
| 484 |
+
"""
|
| 485 |
+
期限切れセッションをクリーンアップする(内部使用)
|
| 486 |
+
|
| 487 |
+
Args:
|
| 488 |
+
user_data: ユーザーデータ
|
| 489 |
+
"""
|
| 490 |
+
try:
|
| 491 |
+
sessions = user_data.get("sessions", [])
|
| 492 |
+
current_time = datetime.now()
|
| 493 |
+
|
| 494 |
+
for session in sessions:
|
| 495 |
+
if session.get("is_active", False):
|
| 496 |
+
expires_at = datetime.fromisoformat(session["expires_at"])
|
| 497 |
+
if current_time >= expires_at:
|
| 498 |
+
session["is_active"] = False
|
| 499 |
+
session["expired_at"] = current_time.isoformat()
|
| 500 |
+
|
| 501 |
+
except Exception as e:
|
| 502 |
+
logger.error(f"セッションクリーンアップエラー: {e}")
|
| 503 |
+
|
| 504 |
+
async def cleanup_old_user_data(self, days: int = None) -> int:
|
| 505 |
+
"""
|
| 506 |
+
古いユーザーデータを削除する
|
| 507 |
+
|
| 508 |
+
Args:
|
| 509 |
+
days: 保持日数(指定しない場合は設定値を使用)
|
| 510 |
+
|
| 511 |
+
Returns:
|
| 512 |
+
int: 削除されたエントリ数
|
| 513 |
+
"""
|
| 514 |
+
try:
|
| 515 |
+
if days is None:
|
| 516 |
+
days = self.user_data_retention_days
|
| 517 |
+
|
| 518 |
+
cutoff_date = datetime.now() - timedelta(days=days)
|
| 519 |
+
cutoff_str = cutoff_date.strftime("%Y-%m-%d")
|
| 520 |
+
|
| 521 |
+
all_users = await self.storage.get_all_users()
|
| 522 |
+
deleted_count = 0
|
| 523 |
+
|
| 524 |
+
for user_id in all_users:
|
| 525 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 526 |
+
|
| 527 |
+
# 古い履歴エントリを削除
|
| 528 |
+
history = user_data.get("history", [])
|
| 529 |
+
original_count = len(history)
|
| 530 |
+
|
| 531 |
+
user_data["history"] = [
|
| 532 |
+
entry for entry in history
|
| 533 |
+
if entry.get("timestamp", "").split("T")[0] >= cutoff_str
|
| 534 |
+
]
|
| 535 |
+
|
| 536 |
+
deleted_count += original_count - len(user_data["history"])
|
| 537 |
+
|
| 538 |
+
# 古いセッションを削除
|
| 539 |
+
sessions = user_data.get("sessions", [])
|
| 540 |
+
original_session_count = len(sessions)
|
| 541 |
+
|
| 542 |
+
user_data["sessions"] = [
|
| 543 |
+
session for session in sessions
|
| 544 |
+
if session.get("created_at", "").split("T")[0] >= cutoff_str
|
| 545 |
+
]
|
| 546 |
+
|
| 547 |
+
deleted_count += original_session_count - len(user_data["sessions"])
|
| 548 |
+
|
| 549 |
+
if (original_count != len(user_data["history"]) or
|
| 550 |
+
original_session_count != len(user_data["sessions"])):
|
| 551 |
+
await self.storage.update_user_data(user_id, user_data)
|
| 552 |
+
|
| 553 |
+
if deleted_count > 0:
|
| 554 |
+
logger.info(f"{deleted_count}件の古いユーザーデータを削除しました")
|
| 555 |
+
|
| 556 |
+
return deleted_count
|
| 557 |
+
|
| 558 |
+
except Exception as e:
|
| 559 |
+
logger.error(f"古いユーザーデータ削除エラー: {e}")
|
| 560 |
+
return 0
|
| 561 |
+
|
| 562 |
+
async def get_user_statistics(self) -> Dict[str, Any]:
|
| 563 |
+
"""
|
| 564 |
+
ユーザーの統計情報を取得する
|
| 565 |
+
|
| 566 |
+
Returns:
|
| 567 |
+
Dict: 統計情報
|
| 568 |
+
"""
|
| 569 |
+
try:
|
| 570 |
+
all_users = await self.storage.get_all_users()
|
| 571 |
+
today = datetime.now().strftime("%Y-%m-%d")
|
| 572 |
+
week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
| 573 |
+
|
| 574 |
+
stats = {
|
| 575 |
+
"total_users": len(all_users),
|
| 576 |
+
"active_users_today": 0,
|
| 577 |
+
"active_users_week": 0,
|
| 578 |
+
"total_letters": 0,
|
| 579 |
+
"total_requests": 0,
|
| 580 |
+
"total_sessions": 0,
|
| 581 |
+
"active_sessions": 0,
|
| 582 |
+
"date": today
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
for user_id in all_users:
|
| 586 |
+
user_data = await self.storage.get_user_data(user_id)
|
| 587 |
+
|
| 588 |
+
# 手紙とリクエストの総数
|
| 589 |
+
stats["total_letters"] += len(user_data.get("letters", {}))
|
| 590 |
+
stats["total_requests"] += len(user_data.get("requests", {}))
|
| 591 |
+
|
| 592 |
+
# セッション統計
|
| 593 |
+
sessions = user_data.get("sessions", [])
|
| 594 |
+
stats["total_sessions"] += len(sessions)
|
| 595 |
+
|
| 596 |
+
for session in sessions:
|
| 597 |
+
if session.get("is_active", False):
|
| 598 |
+
expires_at = datetime.fromisoformat(session["expires_at"])
|
| 599 |
+
if datetime.now() < expires_at:
|
| 600 |
+
stats["active_sessions"] += 1
|
| 601 |
+
|
| 602 |
+
# アクティブユーザー統計
|
| 603 |
+
last_activity = self._calculate_last_activity(user_data)
|
| 604 |
+
if last_activity:
|
| 605 |
+
activity_date = last_activity.split("T")[0]
|
| 606 |
+
if activity_date >= today:
|
| 607 |
+
stats["active_users_today"] += 1
|
| 608 |
+
if activity_date >= week_ago:
|
| 609 |
+
stats["active_users_week"] += 1
|
| 610 |
+
|
| 611 |
+
return stats
|
| 612 |
+
|
| 613 |
+
except Exception as e:
|
| 614 |
+
logger.error(f"ユーザー統計取得エラー: {e}")
|
| 615 |
+
return {"error": str(e)}
|
| 616 |
+
|
| 617 |
+
|
| 618 |
+
# テスト用の関数
|
| 619 |
+
async def test_user_manager():
|
| 620 |
+
"""UserManagerのテスト"""
|
| 621 |
+
import tempfile
|
| 622 |
+
from async_storage_manager import AsyncStorageManager
|
| 623 |
+
|
| 624 |
+
# 一時ディレクトリでテスト
|
| 625 |
+
with tempfile.TemporaryDirectory() as temp_dir:
|
| 626 |
+
test_file = os.path.join(temp_dir, "test_letters.json")
|
| 627 |
+
storage = AsyncStorageManager(test_file)
|
| 628 |
+
user_manager = UserManager(storage)
|
| 629 |
+
|
| 630 |
+
print("=== UserManagerテスト開始 ===")
|
| 631 |
+
|
| 632 |
+
# ユーザーID生成テスト
|
| 633 |
+
user_id = user_manager.generate_user_id()
|
| 634 |
+
print(f"✓ ユーザーID生成テスト: {user_id}")
|
| 635 |
+
|
| 636 |
+
# プロファイル取得テスト
|
| 637 |
+
profile = await user_manager.get_user_profile(user_id)
|
| 638 |
+
print(f"✓ プロファイル取得テスト: {profile}")
|
| 639 |
+
|
| 640 |
+
# 履歴更新テスト
|
| 641 |
+
interaction = {
|
| 642 |
+
"type": "letter_request",
|
| 643 |
+
"action": "submit_request",
|
| 644 |
+
"details": {"theme": "テストテーマ"}
|
| 645 |
+
}
|
| 646 |
+
updated = await user_manager.update_user_history(user_id, interaction)
|
| 647 |
+
print(f"✓ 履歴更新テスト: {updated}")
|
| 648 |
+
|
| 649 |
+
# セッション作成テスト
|
| 650 |
+
session_info = {"ip_address": "127.0.0.1", "user_agent": "test"}
|
| 651 |
+
session_id = await user_manager.create_user_session(user_id, session_info)
|
| 652 |
+
print(f"✓ セッション作成テスト: {session_id}")
|
| 653 |
+
|
| 654 |
+
# セッション検証テスト
|
| 655 |
+
valid = await user_manager.validate_user_session(user_id, session_id)
|
| 656 |
+
print(f"✓ セッション検証テスト: {valid}")
|
| 657 |
+
|
| 658 |
+
# 統計情報取得テスト
|
| 659 |
+
stats = await user_manager.get_user_statistics()
|
| 660 |
+
print(f"✓ 統計情報取得テスト: {stats}")
|
| 661 |
+
|
| 662 |
+
print("=== 全てのテストが完了しました! ===")
|
| 663 |
+
|
| 664 |
+
|
| 665 |
+
if __name__ == "__main__":
|
| 666 |
+
asyncio.run(test_user_manager())
|
local_user_id_manager.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ユーザーID永続化管理モジュール
|
| 3 |
+
ローカル環境でユーザーIDをファイルに保存し、仮想環境を閉じても継続してプレイできるようにする
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import uuid
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from typing import Optional, Dict, Any
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class UserIDManager:
|
| 15 |
+
"""ユーザーIDの永続化を管理するクラス"""
|
| 16 |
+
|
| 17 |
+
def __init__(self, storage_dir: str = "user_data"):
|
| 18 |
+
"""
|
| 19 |
+
Args:
|
| 20 |
+
storage_dir: ユーザーデータを保存するディレクトリ
|
| 21 |
+
"""
|
| 22 |
+
self.storage_dir = storage_dir
|
| 23 |
+
self.user_id_file = os.path.join(storage_dir, "user_id.json")
|
| 24 |
+
self._ensure_storage_dir()
|
| 25 |
+
|
| 26 |
+
def _ensure_storage_dir(self):
|
| 27 |
+
"""ストレージディレクトリが存在することを確認"""
|
| 28 |
+
try:
|
| 29 |
+
if not os.path.exists(self.storage_dir):
|
| 30 |
+
os.makedirs(self.storage_dir)
|
| 31 |
+
logger.info(f"ユーザーデータディレクトリを作成: {self.storage_dir}")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"ストレージディレクトリ作成エラー: {e}")
|
| 34 |
+
|
| 35 |
+
def get_or_create_user_id(self) -> str:
|
| 36 |
+
"""
|
| 37 |
+
保存されたユーザーIDを取得するか、新規作成する
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
ユーザーID
|
| 41 |
+
"""
|
| 42 |
+
try:
|
| 43 |
+
# 既存のユーザーIDファイルをチェック
|
| 44 |
+
if os.path.exists(self.user_id_file):
|
| 45 |
+
user_data = self._load_user_data()
|
| 46 |
+
if user_data and "user_id" in user_data:
|
| 47 |
+
user_id = user_data["user_id"]
|
| 48 |
+
logger.info(f"既存のユーザーIDを読み込み: {user_id[:8]}...")
|
| 49 |
+
|
| 50 |
+
# 最終アクセス時刻を更新
|
| 51 |
+
self._update_last_access(user_id)
|
| 52 |
+
return user_id
|
| 53 |
+
|
| 54 |
+
# 新規ユーザーIDを作成
|
| 55 |
+
user_id = self._generate_new_user_id()
|
| 56 |
+
self._save_user_data(user_id)
|
| 57 |
+
logger.info(f"新規ユーザーIDを作成: {user_id[:8]}...")
|
| 58 |
+
return user_id
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error(f"ユーザーID取得エラー: {e}")
|
| 62 |
+
# フォールバック: 一時的なIDを生成
|
| 63 |
+
return str(uuid.uuid4())
|
| 64 |
+
|
| 65 |
+
def _generate_new_user_id(self) -> str:
|
| 66 |
+
"""新しいユーザーIDを生成"""
|
| 67 |
+
return str(uuid.uuid4())
|
| 68 |
+
|
| 69 |
+
def _load_user_data(self) -> Optional[Dict[str, Any]]:
|
| 70 |
+
"""ユーザーデータファイルを読み込み"""
|
| 71 |
+
try:
|
| 72 |
+
with open(self.user_id_file, 'r', encoding='utf-8') as f:
|
| 73 |
+
data = json.load(f)
|
| 74 |
+
return data
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"ユーザーデータ読み込みエラー: {e}")
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
def _save_user_data(self, user_id: str, game_data: Optional[Dict[str, Any]] = None):
|
| 80 |
+
"""ユーザーデータをファイルに保存"""
|
| 81 |
+
try:
|
| 82 |
+
user_data = {
|
| 83 |
+
"user_id": user_id,
|
| 84 |
+
"created_at": datetime.now().isoformat(),
|
| 85 |
+
"last_access": datetime.now().isoformat(),
|
| 86 |
+
"version": "1.0",
|
| 87 |
+
"game_data": game_data or {}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
with open(self.user_id_file, 'w', encoding='utf-8') as f:
|
| 91 |
+
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
| 92 |
+
|
| 93 |
+
logger.info(f"ユーザーデータを保存: {self.user_id_file}")
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
logger.error(f"ユーザーデータ保存エラー: {e}")
|
| 97 |
+
|
| 98 |
+
def _update_last_access(self, user_id: str):
|
| 99 |
+
"""最終アクセス時刻を更新"""
|
| 100 |
+
try:
|
| 101 |
+
user_data = self._load_user_data()
|
| 102 |
+
if user_data:
|
| 103 |
+
user_data["last_access"] = datetime.now().isoformat()
|
| 104 |
+
|
| 105 |
+
with open(self.user_id_file, 'w', encoding='utf-8') as f:
|
| 106 |
+
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
| 107 |
+
|
| 108 |
+
logger.debug(f"最終アクセス時刻を更新: {user_id[:8]}...")
|
| 109 |
+
|
| 110 |
+
except Exception as e:
|
| 111 |
+
logger.error(f"最終アクセス時刻更新エラー: {e}")
|
| 112 |
+
|
| 113 |
+
def get_user_info(self) -> Optional[Dict[str, Any]]:
|
| 114 |
+
"""ユーザー情報を取得"""
|
| 115 |
+
return self._load_user_data()
|
| 116 |
+
|
| 117 |
+
def delete_user_data(self) -> bool:
|
| 118 |
+
"""
|
| 119 |
+
ユーザーデータを削除(フルリセット用)
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
削除成功かどうか
|
| 123 |
+
"""
|
| 124 |
+
try:
|
| 125 |
+
if os.path.exists(self.user_id_file):
|
| 126 |
+
os.remove(self.user_id_file)
|
| 127 |
+
logger.info(f"ユーザーデータファイルを削除: {self.user_id_file}")
|
| 128 |
+
return True
|
| 129 |
+
else:
|
| 130 |
+
logger.info("削除対象のユーザーデータファイルが存在しません")
|
| 131 |
+
return True
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"ユーザーデータ削除エラー: {e}")
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
def reset_user_id(self) -> str:
|
| 138 |
+
"""
|
| 139 |
+
ユーザーIDをリセットして新規作成
|
| 140 |
+
|
| 141 |
+
Returns:
|
| 142 |
+
新しいユーザーID
|
| 143 |
+
"""
|
| 144 |
+
try:
|
| 145 |
+
# 既存データを削除
|
| 146 |
+
self.delete_user_data()
|
| 147 |
+
|
| 148 |
+
# 新規IDを作成
|
| 149 |
+
new_user_id = self._generate_new_user_id()
|
| 150 |
+
self._save_user_data(new_user_id)
|
| 151 |
+
|
| 152 |
+
logger.info(f"ユーザーIDをリセット: {new_user_id[:8]}...")
|
| 153 |
+
return new_user_id
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"ユーザーIDリセットエラー: {e}")
|
| 157 |
+
return str(uuid.uuid4())
|
| 158 |
+
|
| 159 |
+
def is_user_data_exists(self) -> bool:
|
| 160 |
+
"""ユーザーデータファイルが存在するかチェック"""
|
| 161 |
+
return os.path.exists(self.user_id_file)
|
| 162 |
+
|
| 163 |
+
def get_storage_info(self) -> Dict[str, Any]:
|
| 164 |
+
"""ストレージ情報を取得(デバッグ用)"""
|
| 165 |
+
try:
|
| 166 |
+
info = {
|
| 167 |
+
"storage_dir": self.storage_dir,
|
| 168 |
+
"user_id_file": self.user_id_file,
|
| 169 |
+
"file_exists": os.path.exists(self.user_id_file),
|
| 170 |
+
"dir_exists": os.path.exists(self.storage_dir)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
if info["file_exists"]:
|
| 174 |
+
stat = os.stat(self.user_id_file)
|
| 175 |
+
info["file_size"] = stat.st_size
|
| 176 |
+
info["modified_time"] = datetime.fromtimestamp(stat.st_mtime).isoformat()
|
| 177 |
+
|
| 178 |
+
return info
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"ストレージ情報取得エラー: {e}")
|
| 182 |
+
return {"error": str(e)}
|
| 183 |
+
|
| 184 |
+
def save_game_data(self, user_id: str, game_data: Dict[str, Any]) -> bool:
|
| 185 |
+
"""
|
| 186 |
+
ゲームデータを保存
|
| 187 |
+
|
| 188 |
+
Args:
|
| 189 |
+
user_id: ユーザーID
|
| 190 |
+
game_data: 保存するゲームデータ
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
保存成功かどうか
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
user_data = self._load_user_data()
|
| 197 |
+
if not user_data:
|
| 198 |
+
# ユーザーデータが存在しない場合は新規作成
|
| 199 |
+
user_data = {
|
| 200 |
+
"user_id": user_id,
|
| 201 |
+
"created_at": datetime.now().isoformat(),
|
| 202 |
+
"version": "1.0"
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
# ゲームデータを更新
|
| 206 |
+
user_data["game_data"] = game_data
|
| 207 |
+
user_data["last_access"] = datetime.now().isoformat()
|
| 208 |
+
|
| 209 |
+
with open(self.user_id_file, 'w', encoding='utf-8') as f:
|
| 210 |
+
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
| 211 |
+
|
| 212 |
+
logger.info(f"ゲームデータを保存: {user_id[:8]}...")
|
| 213 |
+
return True
|
| 214 |
+
|
| 215 |
+
except Exception as e:
|
| 216 |
+
logger.error(f"ゲームデータ保存エラー: {e}")
|
| 217 |
+
return False
|
| 218 |
+
|
| 219 |
+
def load_game_data(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 220 |
+
"""
|
| 221 |
+
ゲームデータを読み込み
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
user_id: ユーザーID
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
ゲームデータ(存在しない場合はNone)
|
| 228 |
+
"""
|
| 229 |
+
try:
|
| 230 |
+
user_data = self._load_user_data()
|
| 231 |
+
if user_data and user_data.get("user_id") == user_id:
|
| 232 |
+
game_data = user_data.get("game_data", {})
|
| 233 |
+
logger.info(f"ゲームデータを読み込み: {user_id[:8]}... (データサイズ: {len(str(game_data))}文字)")
|
| 234 |
+
return game_data
|
| 235 |
+
else:
|
| 236 |
+
logger.info(f"ゲームデータが見つかりません: {user_id[:8]}...")
|
| 237 |
+
return None
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"ゲームデータ読み込みエラー: {e}")
|
| 241 |
+
return None
|
main_app.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
maturi-yoru.jpg
ADDED
|
Git LFS Details
|
persistent_user_manager.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hugging Face Spaces永続ストレージ対応ユーザー管理システム
|
| 3 |
+
Cookieベースのユーザー識別と/mnt/dataでの状態永続化を提供
|
| 4 |
+
"""
|
| 5 |
+
import os
|
| 6 |
+
import json
|
| 7 |
+
import uuid
|
| 8 |
+
import logging
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Optional, Dict, Any, List
|
| 11 |
+
import streamlit as st
|
| 12 |
+
from streamlit_cookies_manager import EncryptedCookieManager
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
class PersistentUserManager:
|
| 17 |
+
"""永続ストレージ対応ユーザー管理クラス"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, storage_base_path: str = "/mnt/data"):
|
| 20 |
+
"""
|
| 21 |
+
初期化
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
storage_base_path: 永続ストレージのベースパス(HF Spacesでは/mnt/data)
|
| 25 |
+
"""
|
| 26 |
+
# Hugging Face Spacesの永続ストレージパス
|
| 27 |
+
self.storage_base_path = storage_base_path
|
| 28 |
+
self.user_data_dir = os.path.join(storage_base_path, "mari_users")
|
| 29 |
+
self.session_data_dir = os.path.join(storage_base_path, "mari_sessions")
|
| 30 |
+
|
| 31 |
+
# Cookie管理設定
|
| 32 |
+
self.cookie_name = "mari_user_id"
|
| 33 |
+
self.cookie_expiry_days = 30
|
| 34 |
+
|
| 35 |
+
# ディレクトリ作成
|
| 36 |
+
self._ensure_directories()
|
| 37 |
+
|
| 38 |
+
# Cookie管理の初期化
|
| 39 |
+
self.cookies = self._init_cookie_manager()
|
| 40 |
+
|
| 41 |
+
logger.info(f"永続ユーザー管理システム初期化: {self.user_data_dir}")
|
| 42 |
+
|
| 43 |
+
def _ensure_directories(self):
|
| 44 |
+
"""必要なディレクトリを作成"""
|
| 45 |
+
try:
|
| 46 |
+
os.makedirs(self.user_data_dir, exist_ok=True)
|
| 47 |
+
os.makedirs(self.session_data_dir, exist_ok=True)
|
| 48 |
+
logger.info(f"ストレージディレクトリ確認完了: {self.user_data_dir}")
|
| 49 |
+
except Exception as e:
|
| 50 |
+
logger.error(f"ディレクトリ作成エラー: {e}")
|
| 51 |
+
# フォールバック: ローカルディレクトリを使用
|
| 52 |
+
self.user_data_dir = "local_mari_users"
|
| 53 |
+
self.session_data_dir = "local_mari_sessions"
|
| 54 |
+
os.makedirs(self.user_data_dir, exist_ok=True)
|
| 55 |
+
os.makedirs(self.session_data_dir, exist_ok=True)
|
| 56 |
+
logger.warning(f"フォールバック: ローカルディレクトリを使用 {self.user_data_dir}")
|
| 57 |
+
|
| 58 |
+
def _init_cookie_manager(self) -> EncryptedCookieManager:
|
| 59 |
+
"""Cookie管理システムを初期化"""
|
| 60 |
+
try:
|
| 61 |
+
# セキュアなパスワードを生成(環境変数から取得、なければ生成)
|
| 62 |
+
cookie_password = os.getenv("MARI_COOKIE_PASSWORD", "mari_chat_secure_key_2024")
|
| 63 |
+
|
| 64 |
+
cookies = EncryptedCookieManager(
|
| 65 |
+
prefix="mari_",
|
| 66 |
+
password=cookie_password
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Cookieが準備できるまで待機
|
| 70 |
+
if not cookies.ready():
|
| 71 |
+
st.stop()
|
| 72 |
+
|
| 73 |
+
logger.info("Cookie管理システム初期化完了")
|
| 74 |
+
return cookies
|
| 75 |
+
|
| 76 |
+
except Exception as e:
|
| 77 |
+
logger.error(f"Cookie管理初期化エラー: {e}")
|
| 78 |
+
# フォールバック: セッション状態のみ使用
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
def get_or_create_user_id(self) -> str:
|
| 82 |
+
"""
|
| 83 |
+
ユーザーIDを取得または新規作成(Cookie連携)
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
ユーザーID
|
| 87 |
+
"""
|
| 88 |
+
try:
|
| 89 |
+
# 1. CookieからユーザーIDを取得
|
| 90 |
+
user_id = self._get_user_id_from_cookie()
|
| 91 |
+
|
| 92 |
+
if user_id and self._is_valid_user_id(user_id):
|
| 93 |
+
# 有効なユーザーIDが存在
|
| 94 |
+
self._update_user_access_time(user_id)
|
| 95 |
+
logger.info(f"既存ユーザーID使用: {user_id[:8]}...")
|
| 96 |
+
return user_id
|
| 97 |
+
|
| 98 |
+
# 2. セッション状態から取得を試行
|
| 99 |
+
user_id = st.session_state.get('persistent_user_id')
|
| 100 |
+
if user_id and self._is_valid_user_id(user_id):
|
| 101 |
+
# セッション状態にあるIDを使用してCookieを更新
|
| 102 |
+
self._set_user_id_cookie(user_id)
|
| 103 |
+
self._update_user_access_time(user_id)
|
| 104 |
+
logger.info(f"セッション状態からユーザーID復元: {user_id[:8]}...")
|
| 105 |
+
return user_id
|
| 106 |
+
|
| 107 |
+
# 3. 新規ユーザーIDを作成
|
| 108 |
+
user_id = self._create_new_user()
|
| 109 |
+
logger.info(f"新規ユーザーID作成: {user_id[:8]}...")
|
| 110 |
+
return user_id
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"ユーザーID取得エラー: {e}")
|
| 114 |
+
# フォールバック: 一時的なIDを生成
|
| 115 |
+
fallback_id = str(uuid.uuid4())
|
| 116 |
+
st.session_state.persistent_user_id = fallback_id
|
| 117 |
+
return fallback_id
|
| 118 |
+
|
| 119 |
+
def _get_user_id_from_cookie(self) -> Optional[str]:
|
| 120 |
+
"""CookieからユーザーIDを取得"""
|
| 121 |
+
try:
|
| 122 |
+
if self.cookies is None:
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
user_id = self.cookies.get(self.cookie_name)
|
| 126 |
+
if user_id and self._is_valid_uuid(user_id):
|
| 127 |
+
return user_id
|
| 128 |
+
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.warning(f"Cookie取得エラー: {e}")
|
| 133 |
+
return None
|
| 134 |
+
|
| 135 |
+
def _set_user_id_cookie(self, user_id: str):
|
| 136 |
+
"""ユーザーIDをCookieに設定"""
|
| 137 |
+
try:
|
| 138 |
+
if self.cookies is None:
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
# Cookieの有効期限を設定
|
| 142 |
+
expiry_date = datetime.now() + timedelta(days=self.cookie_expiry_days)
|
| 143 |
+
|
| 144 |
+
self.cookies[self.cookie_name] = user_id
|
| 145 |
+
self.cookies.save()
|
| 146 |
+
|
| 147 |
+
logger.debug(f"ユーザーIDをCookieに保存: {user_id[:8]}...")
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.warning(f"Cookie設定エラー: {e}")
|
| 151 |
+
|
| 152 |
+
def _is_valid_uuid(self, uuid_string: str) -> bool:
|
| 153 |
+
"""UUIDの形式チェック"""
|
| 154 |
+
try:
|
| 155 |
+
uuid.UUID(uuid_string, version=4)
|
| 156 |
+
return True
|
| 157 |
+
except (ValueError, TypeError):
|
| 158 |
+
return False
|
| 159 |
+
|
| 160 |
+
def _is_valid_user_id(self, user_id: str) -> bool:
|
| 161 |
+
"""ユーザーIDの有効性チェック"""
|
| 162 |
+
try:
|
| 163 |
+
if not self._is_valid_uuid(user_id):
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 167 |
+
return os.path.exists(user_file)
|
| 168 |
+
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.warning(f"ユーザーID検証エラー: {e}")
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
def _create_new_user(self) -> str:
|
| 174 |
+
"""新規ユーザーを作成"""
|
| 175 |
+
try:
|
| 176 |
+
user_id = str(uuid.uuid4())
|
| 177 |
+
|
| 178 |
+
# ユーザーデータを作成
|
| 179 |
+
user_data = {
|
| 180 |
+
"user_id": user_id,
|
| 181 |
+
"created_at": datetime.now().isoformat(),
|
| 182 |
+
"last_access": datetime.now().isoformat(),
|
| 183 |
+
"version": "1.0",
|
| 184 |
+
"game_data": {
|
| 185 |
+
"affection": 30,
|
| 186 |
+
"messages": [{"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}],
|
| 187 |
+
"scene_params": {"theme": "default"},
|
| 188 |
+
"ura_mode": False
|
| 189 |
+
},
|
| 190 |
+
"settings": {
|
| 191 |
+
"notifications_enabled": True,
|
| 192 |
+
"auto_save": True
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
# ファイルに保存
|
| 197 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 198 |
+
with open(user_file, 'w', encoding='utf-8') as f:
|
| 199 |
+
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
| 200 |
+
|
| 201 |
+
# Cookieとセッション状態に保存
|
| 202 |
+
self._set_user_id_cookie(user_id)
|
| 203 |
+
st.session_state.persistent_user_id = user_id
|
| 204 |
+
|
| 205 |
+
logger.info(f"新規ユーザー作成完了: {user_id[:8]}...")
|
| 206 |
+
return user_id
|
| 207 |
+
|
| 208 |
+
except Exception as e:
|
| 209 |
+
logger.error(f"新規ユーザー作成エラー: {e}")
|
| 210 |
+
# フォールバック
|
| 211 |
+
fallback_id = str(uuid.uuid4())
|
| 212 |
+
st.session_state.persistent_user_id = fallback_id
|
| 213 |
+
return fallback_id
|
| 214 |
+
|
| 215 |
+
def _update_user_access_time(self, user_id: str):
|
| 216 |
+
"""ユーザーの最終アクセス時刻を更新"""
|
| 217 |
+
try:
|
| 218 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 219 |
+
|
| 220 |
+
if os.path.exists(user_file):
|
| 221 |
+
with open(user_file, 'r', encoding='utf-8') as f:
|
| 222 |
+
user_data = json.load(f)
|
| 223 |
+
|
| 224 |
+
user_data["last_access"] = datetime.now().isoformat()
|
| 225 |
+
|
| 226 |
+
with open(user_file, 'w', encoding='utf-8') as f:
|
| 227 |
+
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
| 228 |
+
|
| 229 |
+
logger.debug(f"ユーザーアクセス時刻更新: {user_id[:8]}...")
|
| 230 |
+
|
| 231 |
+
except Exception as e:
|
| 232 |
+
logger.warning(f"アクセス時刻更新エラー: {e}")
|
| 233 |
+
|
| 234 |
+
def load_user_game_data(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 235 |
+
"""
|
| 236 |
+
ユーザーのゲームデータを読み込み
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
user_id: ユーザーID
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
ゲームデータ(存在しない場合はNone)
|
| 243 |
+
"""
|
| 244 |
+
try:
|
| 245 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 246 |
+
|
| 247 |
+
if not os.path.exists(user_file):
|
| 248 |
+
logger.info(f"ユーザーファイルが存在しません: {user_id[:8]}...")
|
| 249 |
+
return None
|
| 250 |
+
|
| 251 |
+
with open(user_file, 'r', encoding='utf-8') as f:
|
| 252 |
+
user_data = json.load(f)
|
| 253 |
+
|
| 254 |
+
game_data = user_data.get("game_data", {})
|
| 255 |
+
logger.info(f"ゲームデータ読み込み完了: {user_id[:8]}... (データサイズ: {len(str(game_data))}文字)")
|
| 256 |
+
|
| 257 |
+
return game_data
|
| 258 |
+
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.error(f"ゲームデータ読み込みエラー: {e}")
|
| 261 |
+
return None
|
| 262 |
+
|
| 263 |
+
def save_user_game_data(self, user_id: str, game_data: Dict[str, Any]) -> bool:
|
| 264 |
+
"""
|
| 265 |
+
ユーザーのゲームデータを保存
|
| 266 |
+
|
| 267 |
+
Args:
|
| 268 |
+
user_id: ユーザーID
|
| 269 |
+
game_data: 保存するゲームデータ
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
保存成功時True
|
| 273 |
+
"""
|
| 274 |
+
try:
|
| 275 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 276 |
+
|
| 277 |
+
# 既存データを読み込み
|
| 278 |
+
if os.path.exists(user_file):
|
| 279 |
+
with open(user_file, 'r', encoding='utf-8') as f:
|
| 280 |
+
user_data = json.load(f)
|
| 281 |
+
else:
|
| 282 |
+
# 新規ユーザーデータを作成
|
| 283 |
+
user_data = {
|
| 284 |
+
"user_id": user_id,
|
| 285 |
+
"created_at": datetime.now().isoformat(),
|
| 286 |
+
"version": "1.0"
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
# ゲームデータを更新
|
| 290 |
+
user_data["game_data"] = game_data
|
| 291 |
+
user_data["last_access"] = datetime.now().isoformat()
|
| 292 |
+
user_data["last_save"] = datetime.now().isoformat()
|
| 293 |
+
|
| 294 |
+
# ファイルに保存
|
| 295 |
+
with open(user_file, 'w', encoding='utf-8') as f:
|
| 296 |
+
json.dump(user_data, f, ensure_ascii=False, indent=2)
|
| 297 |
+
|
| 298 |
+
logger.info(f"ゲームデータ保存完了: {user_id[:8]}...")
|
| 299 |
+
return True
|
| 300 |
+
|
| 301 |
+
except Exception as e:
|
| 302 |
+
logger.error(f"ゲームデータ保存エラー: {e}")
|
| 303 |
+
return False
|
| 304 |
+
|
| 305 |
+
def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 306 |
+
"""
|
| 307 |
+
ユーザー情報を取得
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
user_id: ユーザーID
|
| 311 |
+
|
| 312 |
+
Returns:
|
| 313 |
+
ユーザー情報
|
| 314 |
+
"""
|
| 315 |
+
try:
|
| 316 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 317 |
+
|
| 318 |
+
if os.path.exists(user_file):
|
| 319 |
+
with open(user_file, 'r', encoding='utf-8') as f:
|
| 320 |
+
return json.load(f)
|
| 321 |
+
|
| 322 |
+
return None
|
| 323 |
+
|
| 324 |
+
except Exception as e:
|
| 325 |
+
logger.error(f"ユーザー情報取得エラー: {e}")
|
| 326 |
+
return None
|
| 327 |
+
|
| 328 |
+
def delete_user_data(self, user_id: str) -> bool:
|
| 329 |
+
"""
|
| 330 |
+
ユーザーデータを削除
|
| 331 |
+
|
| 332 |
+
Args:
|
| 333 |
+
user_id: 削除するユーザーID
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
削除成功時True
|
| 337 |
+
"""
|
| 338 |
+
try:
|
| 339 |
+
user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
|
| 340 |
+
|
| 341 |
+
if os.path.exists(user_file):
|
| 342 |
+
os.remove(user_file)
|
| 343 |
+
logger.info(f"ユーザーデータ削除: {user_id[:8]}...")
|
| 344 |
+
|
| 345 |
+
# Cookieも削除
|
| 346 |
+
if self.cookies:
|
| 347 |
+
if self.cookie_name in self.cookies:
|
| 348 |
+
del self.cookies[self.cookie_name]
|
| 349 |
+
self.cookies.save()
|
| 350 |
+
|
| 351 |
+
# セッション状態からも削除
|
| 352 |
+
if 'persistent_user_id' in st.session_state:
|
| 353 |
+
del st.session_state.persistent_user_id
|
| 354 |
+
|
| 355 |
+
return True
|
| 356 |
+
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logger.error(f"ユーザーデータ削除エラー: {e}")
|
| 359 |
+
return False
|
| 360 |
+
|
| 361 |
+
def list_all_users(self) -> List[Dict[str, Any]]:
|
| 362 |
+
"""
|
| 363 |
+
全ユーザーの一覧を取得(管理用)
|
| 364 |
+
|
| 365 |
+
Returns:
|
| 366 |
+
ユーザー情報のリスト
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
users = []
|
| 370 |
+
|
| 371 |
+
for filename in os.listdir(self.user_data_dir):
|
| 372 |
+
if filename.endswith('.json'):
|
| 373 |
+
user_file = os.path.join(self.user_data_dir, filename)
|
| 374 |
+
|
| 375 |
+
try:
|
| 376 |
+
with open(user_file, 'r', encoding='utf-8') as f:
|
| 377 |
+
user_data = json.load(f)
|
| 378 |
+
|
| 379 |
+
# 基本情報のみ抽出
|
| 380 |
+
user_info = {
|
| 381 |
+
"user_id": user_data.get("user_id", "unknown")[:8] + "...",
|
| 382 |
+
"created_at": user_data.get("created_at", "unknown"),
|
| 383 |
+
"last_access": user_data.get("last_access", "unknown"),
|
| 384 |
+
"has_game_data": "game_data" in user_data,
|
| 385 |
+
"file_size": os.path.getsize(user_file)
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
users.append(user_info)
|
| 389 |
+
|
| 390 |
+
except Exception as e:
|
| 391 |
+
logger.warning(f"ユーザーファイル読み込みエラー {filename}: {e}")
|
| 392 |
+
|
| 393 |
+
return users
|
| 394 |
+
|
| 395 |
+
except Exception as e:
|
| 396 |
+
logger.error(f"ユーザー一覧取得エラー: {e}")
|
| 397 |
+
return []
|
| 398 |
+
|
| 399 |
+
def cleanup_old_users(self, days_threshold: int = 30) -> int:
|
| 400 |
+
"""
|
| 401 |
+
古いユーザーデータをクリーンアップ
|
| 402 |
+
|
| 403 |
+
Args:
|
| 404 |
+
days_threshold: 削除対象の日数閾値
|
| 405 |
+
|
| 406 |
+
Returns:
|
| 407 |
+
削除されたユーザー数
|
| 408 |
+
"""
|
| 409 |
+
try:
|
| 410 |
+
current_time = datetime.now()
|
| 411 |
+
deleted_count = 0
|
| 412 |
+
|
| 413 |
+
for filename in os.listdir(self.user_data_dir):
|
| 414 |
+
if filename.endswith('.json'):
|
| 415 |
+
user_file = os.path.join(self.user_data_dir, filename)
|
| 416 |
+
|
| 417 |
+
try:
|
| 418 |
+
with open(user_file, 'r', encoding='utf-8') as f:
|
| 419 |
+
user_data = json.load(f)
|
| 420 |
+
|
| 421 |
+
last_access = datetime.fromisoformat(user_data.get("last_access", ""))
|
| 422 |
+
if (current_time - last_access).days > days_threshold:
|
| 423 |
+
os.remove(user_file)
|
| 424 |
+
deleted_count += 1
|
| 425 |
+
logger.info(f"古いユーザーデータ削除: {filename}")
|
| 426 |
+
|
| 427 |
+
except Exception as e:
|
| 428 |
+
logger.warning(f"クリーンアップ処理エラー {filename}: {e}")
|
| 429 |
+
|
| 430 |
+
logger.info(f"ユーザーデータクリーンアップ完了: {deleted_count}件削除")
|
| 431 |
+
return deleted_count
|
| 432 |
+
|
| 433 |
+
except Exception as e:
|
| 434 |
+
logger.error(f"クリーンアップエラー: {e}")
|
| 435 |
+
return 0
|
| 436 |
+
|
| 437 |
+
def get_storage_stats(self) -> Dict[str, Any]:
|
| 438 |
+
"""
|
| 439 |
+
ストレージ使用状況を取得
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
ストレージ統計情報
|
| 443 |
+
"""
|
| 444 |
+
try:
|
| 445 |
+
stats = {
|
| 446 |
+
"user_count": 0,
|
| 447 |
+
"total_size": 0,
|
| 448 |
+
"storage_path": self.user_data_dir,
|
| 449 |
+
"cookie_enabled": self.cookies is not None
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
if os.path.exists(self.user_data_dir):
|
| 453 |
+
for filename in os.listdir(self.user_data_dir):
|
| 454 |
+
if filename.endswith('.json'):
|
| 455 |
+
user_file = os.path.join(self.user_data_dir, filename)
|
| 456 |
+
stats["user_count"] += 1
|
| 457 |
+
stats["total_size"] += os.path.getsize(user_file)
|
| 458 |
+
|
| 459 |
+
return stats
|
| 460 |
+
|
| 461 |
+
except Exception as e:
|
| 462 |
+
logger.error(f"ストレージ統計取得エラー: {e}")
|
| 463 |
+
return {"error": str(e)}
|
requirements.txt
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit>=1.28.0
|
| 2 |
+
python-dotenv>=1.0.0
|
| 3 |
+
openai>=1.0.0
|
| 4 |
+
groq>=0.4.0
|
| 5 |
+
requests>=2.25.0
|
| 6 |
+
unidic-lite
|
| 7 |
+
typing-extensions>=4.0.0
|
| 8 |
+
transformers>=4.20.0
|
| 9 |
+
torch>=1.12.0
|
| 10 |
+
sentencepiece>=0.1.95
|
| 11 |
+
fugashi>=1.1.0
|
| 12 |
+
ipadic>=1.0.0
|
| 13 |
+
aiohttp>=3.8.0
|
| 14 |
+
aiofiles>=23.0.0
|
| 15 |
+
asyncio-throttle>=1.0.0
|
| 16 |
+
pydantic>=2.0.0
|
| 17 |
+
fastapi>=0.104.0
|
| 18 |
+
uvicorn>=0.24.0
|
requirements_persistent.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 永続ストレージ対応ユーザー管理システム用の追加依存関係
|
| 2 |
+
streamlit-cookies-manager==0.2.0
|
requirements_session.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FastAPIセッション管理サーバー用の追加依存関係
|
| 2 |
+
fastapi==0.104.1
|
| 3 |
+
uvicorn[standard]==0.24.0
|
| 4 |
+
requests==2.31.0
|
ribinngu-hiru.jpg
ADDED
|
Git LFS Details
|
ribinngu-yoru-on.jpg
ADDED
|
Git LFS Details
|
session_api_client.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPIセッション管理クライアント
|
| 3 |
+
HttpOnlyクッキーによるセキュアなセッション管理のクライアント側実装
|
| 4 |
+
"""
|
| 5 |
+
import requests
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Optional, Dict, Any
|
| 10 |
+
import streamlit as st
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
class SessionAPIClient:
|
| 16 |
+
"""FastAPIセッションサーバーとの通信クライアント"""
|
| 17 |
+
|
| 18 |
+
def __init__(self, api_base_url: str = None):
|
| 19 |
+
"""
|
| 20 |
+
初期化
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
api_base_url: FastAPIサーバーのベースURL(Noneの場合は自動決定)
|
| 24 |
+
"""
|
| 25 |
+
# Hugging Face Spacesでの実行を考慮してベースURLを自動決定
|
| 26 |
+
if api_base_url is None:
|
| 27 |
+
is_spaces = os.getenv("SPACE_ID") is not None
|
| 28 |
+
if is_spaces:
|
| 29 |
+
# Hugging Face Spacesでは同一コンテナ内なのでlocalhostを使用
|
| 30 |
+
api_base_url = "http://localhost:8000"
|
| 31 |
+
else:
|
| 32 |
+
api_base_url = "http://127.0.0.1:8000"
|
| 33 |
+
|
| 34 |
+
self.api_base_url = api_base_url
|
| 35 |
+
self.session = requests.Session()
|
| 36 |
+
|
| 37 |
+
# リクエストタイムアウト設定
|
| 38 |
+
self.timeout = 10
|
| 39 |
+
|
| 40 |
+
# セッション情報をStreamlitの状態に保存
|
| 41 |
+
if 'session_info' not in st.session_state:
|
| 42 |
+
st.session_state.session_info = {}
|
| 43 |
+
|
| 44 |
+
def create_session(self) -> Optional[str]:
|
| 45 |
+
"""
|
| 46 |
+
新しいセッションを作成
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
セッションID(成功時)、None(失敗時)
|
| 50 |
+
"""
|
| 51 |
+
try:
|
| 52 |
+
url = f"{self.api_base_url}/session/create"
|
| 53 |
+
response = self.session.post(url, timeout=self.timeout)
|
| 54 |
+
|
| 55 |
+
if response.status_code == 200:
|
| 56 |
+
data = response.json()
|
| 57 |
+
session_id = data.get('session_id')
|
| 58 |
+
|
| 59 |
+
# セッション情報を保存
|
| 60 |
+
st.session_state.session_info = {
|
| 61 |
+
'session_id': session_id,
|
| 62 |
+
'created_at': datetime.now().isoformat(),
|
| 63 |
+
'status': 'active'
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
logger.info(f"新規セッション作成成功: {session_id[:8]}...")
|
| 67 |
+
return session_id
|
| 68 |
+
else:
|
| 69 |
+
logger.error(f"セッション作成失敗: {response.status_code}")
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
except requests.exceptions.RequestException as e:
|
| 73 |
+
logger.error(f"セッション作成リクエストエラー: {e}")
|
| 74 |
+
return None
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"セッション作成エラー: {e}")
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
def validate_session(self) -> bool:
|
| 80 |
+
"""
|
| 81 |
+
現在のセッションの有効性を検証
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
有効な場合True
|
| 85 |
+
"""
|
| 86 |
+
try:
|
| 87 |
+
# session_infoが存在しない場合は無効とみなす
|
| 88 |
+
if 'session_info' not in st.session_state:
|
| 89 |
+
logger.debug("session_info未存在 - セッション無効")
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
url = f"{self.api_base_url}/session/validate"
|
| 93 |
+
response = self.session.post(url, timeout=self.timeout)
|
| 94 |
+
|
| 95 |
+
if response.status_code == 200:
|
| 96 |
+
data = response.json()
|
| 97 |
+
is_valid = data.get('valid', False)
|
| 98 |
+
|
| 99 |
+
if is_valid:
|
| 100 |
+
# セッション情報を更新
|
| 101 |
+
session_id = data.get('session_id')
|
| 102 |
+
st.session_state.session_info.update({
|
| 103 |
+
'session_id': session_id,
|
| 104 |
+
'last_validated': datetime.now().isoformat(),
|
| 105 |
+
'status': 'active'
|
| 106 |
+
})
|
| 107 |
+
logger.debug(f"セッション検証成功: {session_id[:8]}...")
|
| 108 |
+
else:
|
| 109 |
+
# 無効なセッション
|
| 110 |
+
if 'session_info' in st.session_state:
|
| 111 |
+
st.session_state.session_info['status'] = 'invalid'
|
| 112 |
+
logger.warning("セッション検証失敗: 無効なセッション")
|
| 113 |
+
|
| 114 |
+
return is_valid
|
| 115 |
+
else:
|
| 116 |
+
logger.error(f"セッション検証失敗: {response.status_code}")
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
except requests.exceptions.RequestException as e:
|
| 120 |
+
logger.error(f"セッション検証リクエストエラー: {e}")
|
| 121 |
+
return False
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"セッション検証エラー: {e}")
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
def get_session_info(self) -> Optional[Dict[str, Any]]:
|
| 127 |
+
"""
|
| 128 |
+
セッション情報を取得
|
| 129 |
+
|
| 130 |
+
Returns:
|
| 131 |
+
セッション情報(成功時)、None(失敗時)
|
| 132 |
+
"""
|
| 133 |
+
try:
|
| 134 |
+
url = f"{self.api_base_url}/session/info"
|
| 135 |
+
response = self.session.get(url, timeout=self.timeout)
|
| 136 |
+
|
| 137 |
+
if response.status_code == 200:
|
| 138 |
+
data = response.json()
|
| 139 |
+
|
| 140 |
+
# セッション情報を更新
|
| 141 |
+
st.session_state.session_info.update({
|
| 142 |
+
'session_id': data.get('session_id'),
|
| 143 |
+
'created_at': data.get('created_at'),
|
| 144 |
+
'last_access': data.get('last_access'),
|
| 145 |
+
'status': 'active'
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
return data
|
| 149 |
+
else:
|
| 150 |
+
logger.error(f"セッション情報取得失敗: {response.status_code}")
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
except requests.exceptions.RequestException as e:
|
| 154 |
+
logger.error(f"セッション情報取得リクエストエラー: {e}")
|
| 155 |
+
return None
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.error(f"セッション情報取得エラー: {e}")
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
def delete_session(self) -> bool:
|
| 161 |
+
"""
|
| 162 |
+
現在のセッションを削除
|
| 163 |
+
|
| 164 |
+
Returns:
|
| 165 |
+
削除成功時True
|
| 166 |
+
"""
|
| 167 |
+
try:
|
| 168 |
+
url = f"{self.api_base_url}/session/delete"
|
| 169 |
+
response = self.session.delete(url, timeout=self.timeout)
|
| 170 |
+
|
| 171 |
+
if response.status_code == 200:
|
| 172 |
+
# セッション情報をクリア
|
| 173 |
+
st.session_state.session_info = {
|
| 174 |
+
'status': 'deleted',
|
| 175 |
+
'deleted_at': datetime.now().isoformat()
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
logger.info("セッション削除成功")
|
| 179 |
+
return True
|
| 180 |
+
else:
|
| 181 |
+
logger.error(f"セッション削除失敗: {response.status_code}")
|
| 182 |
+
return False
|
| 183 |
+
|
| 184 |
+
except requests.exceptions.RequestException as e:
|
| 185 |
+
logger.error(f"セッション削除リクエストエラー: {e}")
|
| 186 |
+
return False
|
| 187 |
+
except Exception as e:
|
| 188 |
+
logger.error(f"セッション削除エラー: {e}")
|
| 189 |
+
return False
|
| 190 |
+
|
| 191 |
+
def get_or_create_session_id(self) -> str:
|
| 192 |
+
"""
|
| 193 |
+
セッションIDを取得または新規作成
|
| 194 |
+
複数セッション生成を防ぐため、既存セッション情報を最優先でチェック
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
セッションID
|
| 198 |
+
"""
|
| 199 |
+
try:
|
| 200 |
+
# 既存のセッション情報をチェック
|
| 201 |
+
existing_session_info = st.session_state.get('session_info', {})
|
| 202 |
+
existing_session_id = existing_session_info.get('session_id')
|
| 203 |
+
|
| 204 |
+
# 既存セッションがある場合は、基本的にそれを使用(検証は最小限に)
|
| 205 |
+
if existing_session_id:
|
| 206 |
+
logger.debug(f"既存セッション情報発見: {existing_session_id[:8]}...")
|
| 207 |
+
|
| 208 |
+
# セッション状態をチェック
|
| 209 |
+
session_status = existing_session_info.get('status', 'unknown')
|
| 210 |
+
|
| 211 |
+
# 明示的に無効とマークされていない限り、既存セッションを使用
|
| 212 |
+
if session_status != 'invalid':
|
| 213 |
+
logger.debug(f"既存セッション使用: {existing_session_id[:8]}... (status: {session_status})")
|
| 214 |
+
return existing_session_id
|
| 215 |
+
else:
|
| 216 |
+
logger.info(f"無効セッション検出: {existing_session_id[:8]}... - 新規作成")
|
| 217 |
+
|
| 218 |
+
# 新しいセッションを作成(一度だけ)
|
| 219 |
+
logger.info("新規セッション作成開始...")
|
| 220 |
+
|
| 221 |
+
# セッション作成中フラグを設定(重複作成防止)
|
| 222 |
+
if st.session_state.get('session_creating', False):
|
| 223 |
+
logger.warning("セッション作成中 - 待機")
|
| 224 |
+
# 既存のセッション情報があればそれを返す
|
| 225 |
+
if existing_session_id:
|
| 226 |
+
return existing_session_id
|
| 227 |
+
# なければフォールバック
|
| 228 |
+
import uuid
|
| 229 |
+
return str(uuid.uuid4())
|
| 230 |
+
|
| 231 |
+
st.session_state.session_creating = True
|
| 232 |
+
|
| 233 |
+
try:
|
| 234 |
+
if self.is_server_available():
|
| 235 |
+
session_id = self.create_session()
|
| 236 |
+
if session_id:
|
| 237 |
+
logger.info(f"新規セッション作成成功: {session_id[:8]}...")
|
| 238 |
+
return session_id
|
| 239 |
+
|
| 240 |
+
# フォールバック: ローカルセッションID生成
|
| 241 |
+
import uuid
|
| 242 |
+
fallback_id = str(uuid.uuid4())
|
| 243 |
+
logger.warning(f"フォールバックセッションID生成: {fallback_id[:8]}...")
|
| 244 |
+
|
| 245 |
+
# フォールバックセッション情報を保存
|
| 246 |
+
st.session_state.session_info = {
|
| 247 |
+
'session_id': fallback_id,
|
| 248 |
+
'created_at': datetime.now().isoformat(),
|
| 249 |
+
'fallback_mode': True,
|
| 250 |
+
'server_available': False,
|
| 251 |
+
'status': 'fallback'
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
return fallback_id
|
| 255 |
+
|
| 256 |
+
finally:
|
| 257 |
+
# セッション作成中フラグをクリア
|
| 258 |
+
st.session_state.session_creating = False
|
| 259 |
+
|
| 260 |
+
except Exception as e:
|
| 261 |
+
logger.error(f"セッションID取得エラー: {e}")
|
| 262 |
+
# セッション作成中フラグをクリア
|
| 263 |
+
st.session_state.session_creating = False
|
| 264 |
+
|
| 265 |
+
# 最終フォールバック
|
| 266 |
+
import uuid
|
| 267 |
+
fallback_id = str(uuid.uuid4())
|
| 268 |
+
logger.error(f"最終フォールバックセッションID: {fallback_id[:8]}...")
|
| 269 |
+
return fallback_id
|
| 270 |
+
|
| 271 |
+
def is_server_available(self) -> bool:
|
| 272 |
+
"""
|
| 273 |
+
FastAPIサーバーが利用可能かチェック
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
利用可能な場合True
|
| 277 |
+
"""
|
| 278 |
+
try:
|
| 279 |
+
url = f"{self.api_base_url}/health"
|
| 280 |
+
response = self.session.get(url, timeout=5)
|
| 281 |
+
return response.status_code == 200
|
| 282 |
+
except:
|
| 283 |
+
return False
|
| 284 |
+
|
| 285 |
+
def get_session_status(self) -> Dict[str, Any]:
|
| 286 |
+
"""
|
| 287 |
+
現在のセッション状態を取得
|
| 288 |
+
|
| 289 |
+
Returns:
|
| 290 |
+
セッション状態の辞書
|
| 291 |
+
"""
|
| 292 |
+
session_info = st.session_state.get('session_info', {})
|
| 293 |
+
|
| 294 |
+
return {
|
| 295 |
+
'session_id': session_info.get('session_id', 'unknown')[:8] + "..." if session_info.get('session_id') else 'none',
|
| 296 |
+
'status': session_info.get('status', 'unknown'),
|
| 297 |
+
'created_at': session_info.get('created_at'),
|
| 298 |
+
'last_access': session_info.get('last_access'),
|
| 299 |
+
'last_validated': session_info.get('last_validated'),
|
| 300 |
+
'server_available': self.is_server_available()
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
def reset_session(self) -> str:
|
| 304 |
+
"""
|
| 305 |
+
セッションをリセット(削除して新規作成)
|
| 306 |
+
|
| 307 |
+
Returns:
|
| 308 |
+
新しいセッションID
|
| 309 |
+
"""
|
| 310 |
+
try:
|
| 311 |
+
# 既存セッションを削除
|
| 312 |
+
self.delete_session()
|
| 313 |
+
|
| 314 |
+
# 新しいセッションを作成
|
| 315 |
+
new_session_id = self.create_session()
|
| 316 |
+
if new_session_id:
|
| 317 |
+
logger.info(f"セッションリセット完了: {new_session_id[:8]}...")
|
| 318 |
+
return new_session_id
|
| 319 |
+
|
| 320 |
+
# フォールバック
|
| 321 |
+
import uuid
|
| 322 |
+
fallback_id = str(uuid.uuid4())
|
| 323 |
+
logger.warning(f"セッションリセット失敗、フォールバック使用: {fallback_id[:8]}...")
|
| 324 |
+
return fallback_id
|
| 325 |
+
|
| 326 |
+
except Exception as e:
|
| 327 |
+
logger.error(f"セッションリセットエラー: {e}")
|
| 328 |
+
import uuid
|
| 329 |
+
return str(uuid.uuid4())
|
| 330 |
+
|
| 331 |
+
def get_cookie_status(self) -> Dict[str, Any]:
|
| 332 |
+
"""
|
| 333 |
+
現在のCookie状態を取得
|
| 334 |
+
|
| 335 |
+
Returns:
|
| 336 |
+
Cookie状態の辞書
|
| 337 |
+
"""
|
| 338 |
+
try:
|
| 339 |
+
cookie_info = {
|
| 340 |
+
'count': len(self.session.cookies),
|
| 341 |
+
'cookies': [],
|
| 342 |
+
'has_session_cookie': False,
|
| 343 |
+
'timestamp': datetime.now().isoformat()
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
for cookie in self.session.cookies:
|
| 347 |
+
cookie_data = {
|
| 348 |
+
'name': cookie.name,
|
| 349 |
+
'domain': cookie.domain,
|
| 350 |
+
'path': cookie.path,
|
| 351 |
+
'secure': cookie.secure,
|
| 352 |
+
'expires': cookie.expires
|
| 353 |
+
}
|
| 354 |
+
cookie_info['cookies'].append(cookie_data)
|
| 355 |
+
|
| 356 |
+
# セッション関連のCookieをチェック
|
| 357 |
+
if 'session' in cookie.name.lower():
|
| 358 |
+
cookie_info['has_session_cookie'] = True
|
| 359 |
+
|
| 360 |
+
return cookie_info
|
| 361 |
+
|
| 362 |
+
except Exception as e:
|
| 363 |
+
logger.error(f"Cookie状態取得エラー: {e}")
|
| 364 |
+
return {
|
| 365 |
+
'count': 0,
|
| 366 |
+
'cookies': [],
|
| 367 |
+
'has_session_cookie': False,
|
| 368 |
+
'error': str(e),
|
| 369 |
+
'timestamp': datetime.now().isoformat()
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
def full_reset_session(self) -> Dict[str, Any]:
|
| 373 |
+
"""
|
| 374 |
+
フルリセット(Cookie削除 + 新規セッション作成)
|
| 375 |
+
サーバー接続エラーでも動作するフォールバック機能付き
|
| 376 |
+
|
| 377 |
+
Returns:
|
| 378 |
+
リセット結果の辞書
|
| 379 |
+
"""
|
| 380 |
+
try:
|
| 381 |
+
result = {
|
| 382 |
+
'success': False,
|
| 383 |
+
'old_session_id': None,
|
| 384 |
+
'new_session_id': None,
|
| 385 |
+
'message': '',
|
| 386 |
+
'timestamp': datetime.now().isoformat(),
|
| 387 |
+
'cookie_reset': False,
|
| 388 |
+
'session_created': False,
|
| 389 |
+
'server_available': False,
|
| 390 |
+
'fallback_mode': False
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
# 現在のセッションIDを記録
|
| 394 |
+
current_session_info = st.session_state.get('session_info', {})
|
| 395 |
+
old_session_id = current_session_info.get('session_id', st.session_state.get('user_id', 'unknown'))
|
| 396 |
+
result['old_session_id'] = old_session_id[:8] + "..." if len(old_session_id) > 8 else old_session_id
|
| 397 |
+
|
| 398 |
+
logger.info(f"フルリセット開始 - 旧セッション: {result['old_session_id']}")
|
| 399 |
+
|
| 400 |
+
# 1. サーバー接続テスト
|
| 401 |
+
server_available = self._test_server_connection()
|
| 402 |
+
result['server_available'] = server_available
|
| 403 |
+
|
| 404 |
+
if server_available:
|
| 405 |
+
# サーバーが利用可能な場合の通常処理
|
| 406 |
+
logger.info("サーバー利用可能 - 通常のフルリセット実行")
|
| 407 |
+
delete_success = self.delete_session()
|
| 408 |
+
logger.info(f"セッション削除結果: {delete_success}")
|
| 409 |
+
else:
|
| 410 |
+
# サーバーが利用できない場合のフォールバック処理
|
| 411 |
+
logger.warning("サーバー接続不可 - フォールバックモードでリセット実行")
|
| 412 |
+
result['fallback_mode'] = True
|
| 413 |
+
|
| 414 |
+
# 2. セッション情報を完全クリア
|
| 415 |
+
if 'session_info' in st.session_state:
|
| 416 |
+
del st.session_state.session_info
|
| 417 |
+
|
| 418 |
+
# 3. 新しいrequestsセッションを作成(Cookie完全クリア)
|
| 419 |
+
old_session = self.session
|
| 420 |
+
cookie_count_before = len(old_session.cookies)
|
| 421 |
+
|
| 422 |
+
self.session.close()
|
| 423 |
+
self.session = requests.Session()
|
| 424 |
+
|
| 425 |
+
# Cookieが完全にクリアされたことを確認
|
| 426 |
+
cookie_count_after = len(self.session.cookies)
|
| 427 |
+
result['cookie_reset'] = cookie_count_after == 0
|
| 428 |
+
|
| 429 |
+
logger.info(f"Cookie状態 - 削除前: {cookie_count_before}個, 削除後: {cookie_count_after}個")
|
| 430 |
+
|
| 431 |
+
# 4. 新しいセッションを作成
|
| 432 |
+
if server_available:
|
| 433 |
+
# サーバー経由でセッション作成
|
| 434 |
+
new_session_id = self.create_session()
|
| 435 |
+
else:
|
| 436 |
+
# フォールバック: ローカルでセッションID生成
|
| 437 |
+
import uuid
|
| 438 |
+
new_session_id = str(uuid.uuid4())
|
| 439 |
+
logger.info(f"フォールバックモード: ローカルセッションID生成 - {new_session_id[:8]}...")
|
| 440 |
+
|
| 441 |
+
if new_session_id:
|
| 442 |
+
result['success'] = True
|
| 443 |
+
result['session_created'] = True
|
| 444 |
+
result['new_session_id'] = new_session_id[:8] + "..."
|
| 445 |
+
|
| 446 |
+
if result['fallback_mode']:
|
| 447 |
+
result['message'] = 'フルリセット成功(フォールバックモード) - Cookie削除&ローカルセッション作成完了'
|
| 448 |
+
else:
|
| 449 |
+
result['message'] = 'フルリセット成功 - Cookie削除&新規セッション作成完了'
|
| 450 |
+
|
| 451 |
+
logger.info(f"フルリセット成功: {result['old_session_id']} → {result['new_session_id']}")
|
| 452 |
+
|
| 453 |
+
# 新しいセッション情報をStreamlitセッションに保存
|
| 454 |
+
st.session_state.session_info = {
|
| 455 |
+
'session_id': new_session_id,
|
| 456 |
+
'created_at': datetime.now().isoformat(),
|
| 457 |
+
'reset_count': st.session_state.get('reset_count', 0) + 1,
|
| 458 |
+
'fallback_mode': result['fallback_mode'],
|
| 459 |
+
'server_available': result['server_available']
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
else:
|
| 463 |
+
result['message'] = 'Cookie削除成功、セッション作成失敗'
|
| 464 |
+
logger.error("フルリセット: 新規セッション作成失敗")
|
| 465 |
+
|
| 466 |
+
return result
|
| 467 |
+
|
| 468 |
+
except Exception as e:
|
| 469 |
+
logger.error(f"フルリセットエラー: {e}")
|
| 470 |
+
return {
|
| 471 |
+
'success': False,
|
| 472 |
+
'old_session_id': 'error',
|
| 473 |
+
'new_session_id': None,
|
| 474 |
+
'message': f'エラー: {str(e)}',
|
| 475 |
+
'timestamp': datetime.now().isoformat(),
|
| 476 |
+
'cookie_reset': False,
|
| 477 |
+
'session_created': False,
|
| 478 |
+
'server_available': False,
|
| 479 |
+
'fallback_mode': True
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
def _test_server_connection(self) -> bool:
|
| 483 |
+
"""
|
| 484 |
+
サーバー接続をテストする
|
| 485 |
+
|
| 486 |
+
Returns:
|
| 487 |
+
接続可能かどうか
|
| 488 |
+
"""
|
| 489 |
+
try:
|
| 490 |
+
response = requests.get(f"{self.base_url}/health", timeout=2)
|
| 491 |
+
return response.status_code == 200
|
| 492 |
+
except Exception as e:
|
| 493 |
+
logger.debug(f"サーバー接続テスト失敗: {e}")
|
| 494 |
+
return False
|
session_api_server.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FastAPIベースのセッション管理サーバー
|
| 3 |
+
HttpOnlyクッキーによるセキュアなセッション管理を提供
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import FastAPI, Request, Response, HTTPException
|
| 6 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 7 |
+
import uuid
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
from typing import Optional, Dict, Any
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
# ログ設定
|
| 16 |
+
logging.basicConfig(level=logging.INFO)
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="Mari Session API", version="1.0.0")
|
| 20 |
+
|
| 21 |
+
# CORS設定(Streamlitからのアクセスを許可)
|
| 22 |
+
app.add_middleware(
|
| 23 |
+
CORSMiddleware,
|
| 24 |
+
allow_origins=[
|
| 25 |
+
"http://localhost:8501",
|
| 26 |
+
"https://localhost:8501",
|
| 27 |
+
"http://127.0.0.1:8501",
|
| 28 |
+
"https://127.0.0.1:8501",
|
| 29 |
+
"*" # Hugging Face Spacesでの実行を考慮
|
| 30 |
+
],
|
| 31 |
+
allow_credentials=True,
|
| 32 |
+
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
| 33 |
+
allow_headers=["*"],
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
class SessionManager:
|
| 37 |
+
"""セッション管理クラス"""
|
| 38 |
+
|
| 39 |
+
def __init__(self, storage_path: str = "session_data"):
|
| 40 |
+
self.storage_path = storage_path
|
| 41 |
+
self.cookie_name = "mari_session_id"
|
| 42 |
+
self.session_duration_days = 7
|
| 43 |
+
self.cleanup_interval_hours = 24
|
| 44 |
+
|
| 45 |
+
# ストレージディレクトリを作成
|
| 46 |
+
os.makedirs(self.storage_path, exist_ok=True)
|
| 47 |
+
|
| 48 |
+
# 最後のクリーンアップ時刻を記録するファイル
|
| 49 |
+
self.cleanup_file = os.path.join(self.storage_path, "last_cleanup.json")
|
| 50 |
+
|
| 51 |
+
def create_session(self) -> str:
|
| 52 |
+
"""新しいセッションを作成"""
|
| 53 |
+
session_id = str(uuid.uuid4())
|
| 54 |
+
|
| 55 |
+
session_data = {
|
| 56 |
+
'session_id': session_id,
|
| 57 |
+
'created_at': datetime.now().isoformat(),
|
| 58 |
+
'last_access': datetime.now().isoformat(),
|
| 59 |
+
'user_data': {}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# セッションファイルに保存
|
| 63 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 64 |
+
with open(session_file, 'w', encoding='utf-8') as f:
|
| 65 |
+
json.dump(session_data, f, ensure_ascii=False, indent=2)
|
| 66 |
+
|
| 67 |
+
logger.info(f"新規セッション作成: {session_id[:8]}...")
|
| 68 |
+
return session_id
|
| 69 |
+
|
| 70 |
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 71 |
+
"""セッションデータを取得"""
|
| 72 |
+
try:
|
| 73 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 74 |
+
|
| 75 |
+
if not os.path.exists(session_file):
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 79 |
+
session_data = json.load(f)
|
| 80 |
+
|
| 81 |
+
# 期限チェック
|
| 82 |
+
last_access = datetime.fromisoformat(session_data.get('last_access', ''))
|
| 83 |
+
expiry_time = last_access + timedelta(days=self.session_duration_days)
|
| 84 |
+
|
| 85 |
+
if datetime.now() > expiry_time:
|
| 86 |
+
# 期限切れセッションを削除
|
| 87 |
+
os.remove(session_file)
|
| 88 |
+
logger.info(f"期限切れセッション削除: {session_id[:8]}...")
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
return session_data
|
| 92 |
+
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"セッション取得エラー: {e}")
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
def update_session_access(self, session_id: str) -> bool:
|
| 98 |
+
"""セッションの最終アクセス時刻を更新"""
|
| 99 |
+
try:
|
| 100 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 101 |
+
|
| 102 |
+
if not os.path.exists(session_file):
|
| 103 |
+
return False
|
| 104 |
+
|
| 105 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 106 |
+
session_data = json.load(f)
|
| 107 |
+
|
| 108 |
+
session_data['last_access'] = datetime.now().isoformat()
|
| 109 |
+
|
| 110 |
+
with open(session_file, 'w', encoding='utf-8') as f:
|
| 111 |
+
json.dump(session_data, f, ensure_ascii=False, indent=2)
|
| 112 |
+
|
| 113 |
+
return True
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"セッションアクセス時刻更新エラー: {e}")
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
def delete_session(self, session_id: str) -> bool:
|
| 120 |
+
"""セッションを削除"""
|
| 121 |
+
try:
|
| 122 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 123 |
+
|
| 124 |
+
if os.path.exists(session_file):
|
| 125 |
+
os.remove(session_file)
|
| 126 |
+
logger.info(f"セッション削除: {session_id[:8]}...")
|
| 127 |
+
return True
|
| 128 |
+
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"セッション削除エラー: {e}")
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
def cleanup_expired_sessions(self):
|
| 136 |
+
"""期限切れセッションのクリーンアップ"""
|
| 137 |
+
try:
|
| 138 |
+
current_time = datetime.now()
|
| 139 |
+
cleaned_count = 0
|
| 140 |
+
|
| 141 |
+
for filename in os.listdir(self.storage_path):
|
| 142 |
+
if filename.endswith('.json') and filename != 'last_cleanup.json':
|
| 143 |
+
session_file = os.path.join(self.storage_path, filename)
|
| 144 |
+
|
| 145 |
+
try:
|
| 146 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 147 |
+
session_data = json.load(f)
|
| 148 |
+
|
| 149 |
+
last_access = datetime.fromisoformat(session_data.get('last_access', ''))
|
| 150 |
+
expiry_time = last_access + timedelta(days=self.session_duration_days)
|
| 151 |
+
|
| 152 |
+
if current_time > expiry_time:
|
| 153 |
+
os.remove(session_file)
|
| 154 |
+
cleaned_count += 1
|
| 155 |
+
|
| 156 |
+
except Exception as e:
|
| 157 |
+
logger.warning(f"セッションファイル処理エラー {filename}: {e}")
|
| 158 |
+
|
| 159 |
+
if cleaned_count > 0:
|
| 160 |
+
logger.info(f"期限切れセッション {cleaned_count}件を削除")
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"セッションクリーンアップエラー: {e}")
|
| 164 |
+
|
| 165 |
+
# セッションマネージャーのインスタンス
|
| 166 |
+
session_manager = SessionManager()
|
| 167 |
+
|
| 168 |
+
@app.post("/session/create")
|
| 169 |
+
async def create_session(response: Response):
|
| 170 |
+
"""新しいセッションを作成してCookieを設定"""
|
| 171 |
+
try:
|
| 172 |
+
# 期限切れセッションのクリーンアップ
|
| 173 |
+
session_manager.cleanup_expired_sessions()
|
| 174 |
+
|
| 175 |
+
# 新しいセッションを作成
|
| 176 |
+
session_id = session_manager.create_session()
|
| 177 |
+
|
| 178 |
+
# HttpOnlyクッキーを設定(Hugging Face Spaces対応)
|
| 179 |
+
is_production = os.getenv("SPACE_ID") is not None # Hugging Face Spacesの環境変数
|
| 180 |
+
|
| 181 |
+
response.set_cookie(
|
| 182 |
+
key=session_manager.cookie_name,
|
| 183 |
+
value=session_id,
|
| 184 |
+
max_age=session_manager.session_duration_days * 24 * 60 * 60, # 7日間
|
| 185 |
+
httponly=True,
|
| 186 |
+
secure=is_production, # 本番環境(HTTPS)でのみSecure属性を有効
|
| 187 |
+
samesite="lax" if is_production else "strict" # 本番環境では緩和
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
return {
|
| 191 |
+
"status": "success",
|
| 192 |
+
"session_id": session_id,
|
| 193 |
+
"message": "セッションが作成されました"
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"セッション作成エラー: {e}")
|
| 198 |
+
raise HTTPException(status_code=500, detail="セッション作成に失敗しました")
|
| 199 |
+
|
| 200 |
+
@app.get("/session/info")
|
| 201 |
+
async def get_session_info(request: Request):
|
| 202 |
+
"""現在のセッション情報を取得"""
|
| 203 |
+
try:
|
| 204 |
+
# CookieからセッションIDを取得
|
| 205 |
+
session_id = request.cookies.get(session_manager.cookie_name)
|
| 206 |
+
|
| 207 |
+
if not session_id:
|
| 208 |
+
raise HTTPException(status_code=401, detail="セッションが見つかりません")
|
| 209 |
+
|
| 210 |
+
# セッションデータを取得
|
| 211 |
+
session_data = session_manager.get_session(session_id)
|
| 212 |
+
|
| 213 |
+
if not session_data:
|
| 214 |
+
raise HTTPException(status_code=401, detail="無効なセッションです")
|
| 215 |
+
|
| 216 |
+
# アクセス時刻を更新
|
| 217 |
+
session_manager.update_session_access(session_id)
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"status": "success",
|
| 221 |
+
"session_id": session_id,
|
| 222 |
+
"created_at": session_data.get('created_at'),
|
| 223 |
+
"last_access": session_data.get('last_access')
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
except HTTPException:
|
| 227 |
+
raise
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"セッション情報取得エラー: {e}")
|
| 230 |
+
raise HTTPException(status_code=500, detail="セッション情報の取得に失敗しました")
|
| 231 |
+
|
| 232 |
+
@app.post("/session/validate")
|
| 233 |
+
async def validate_session(request: Request):
|
| 234 |
+
"""セッションの有効性を検証"""
|
| 235 |
+
try:
|
| 236 |
+
# CookieからセッションIDを取得
|
| 237 |
+
session_id = request.cookies.get(session_manager.cookie_name)
|
| 238 |
+
|
| 239 |
+
if not session_id:
|
| 240 |
+
return {"valid": False, "message": "セッションが見つかりません"}
|
| 241 |
+
|
| 242 |
+
# セッションデータを取得
|
| 243 |
+
session_data = session_manager.get_session(session_id)
|
| 244 |
+
|
| 245 |
+
if not session_data:
|
| 246 |
+
return {"valid": False, "message": "無効なセッションです"}
|
| 247 |
+
|
| 248 |
+
# アクセス時刻を更新
|
| 249 |
+
session_manager.update_session_access(session_id)
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
"valid": True,
|
| 253 |
+
"session_id": session_id,
|
| 254 |
+
"message": "有効なセッションです"
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
except Exception as e:
|
| 258 |
+
logger.error(f"セッション検証エラー: {e}")
|
| 259 |
+
return {"valid": False, "message": "セッション検証に失敗しました"}
|
| 260 |
+
|
| 261 |
+
@app.delete("/session/delete")
|
| 262 |
+
async def delete_session(request: Request, response: Response):
|
| 263 |
+
"""セッションを削除"""
|
| 264 |
+
try:
|
| 265 |
+
# CookieからセッションIDを取得
|
| 266 |
+
session_id = request.cookies.get(session_manager.cookie_name)
|
| 267 |
+
|
| 268 |
+
if session_id:
|
| 269 |
+
# セッションファイルを削除
|
| 270 |
+
session_manager.delete_session(session_id)
|
| 271 |
+
|
| 272 |
+
# Cookieを削除(Hugging Face Spaces対応)
|
| 273 |
+
is_production = os.getenv("SPACE_ID") is not None
|
| 274 |
+
|
| 275 |
+
response.delete_cookie(
|
| 276 |
+
key=session_manager.cookie_name,
|
| 277 |
+
httponly=True,
|
| 278 |
+
secure=is_production,
|
| 279 |
+
samesite="lax" if is_production else "strict"
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
return {
|
| 283 |
+
"status": "success",
|
| 284 |
+
"message": "セッションが削除されました"
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
logger.error(f"セッション削除エラー: {e}")
|
| 289 |
+
raise HTTPException(status_code=500, detail="セッション削除に失敗しました")
|
| 290 |
+
|
| 291 |
+
@app.get("/health")
|
| 292 |
+
async def health_check():
|
| 293 |
+
"""ヘルスチェック"""
|
| 294 |
+
return {"status": "healthy", "timestamp": datetime.now().isoformat()}
|
| 295 |
+
|
| 296 |
+
if __name__ == "__main__":
|
| 297 |
+
import uvicorn
|
| 298 |
+
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
|
session_cookie_manager.py
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
セッションCookie管理システム
|
| 3 |
+
UUIDベースのユーザー識別とCookie管理を提供する
|
| 4 |
+
"""
|
| 5 |
+
import uuid
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
import time
|
| 9 |
+
from datetime import datetime, timedelta
|
| 10 |
+
from typing import Optional, Dict, Any
|
| 11 |
+
import streamlit as st
|
| 12 |
+
import logging
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
class SessionCookieManager:
|
| 17 |
+
"""セッションCookie管理クラス"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, storage_path: str = "session_data"):
|
| 20 |
+
"""
|
| 21 |
+
初期化
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
storage_path: セッションデータの保存パス
|
| 25 |
+
"""
|
| 26 |
+
self.storage_path = storage_path
|
| 27 |
+
self.cookie_name = "mari_session_id"
|
| 28 |
+
self.session_duration_days = 7
|
| 29 |
+
self.cleanup_interval_hours = 24
|
| 30 |
+
|
| 31 |
+
# ストレージディレクトリを作成
|
| 32 |
+
os.makedirs(self.storage_path, exist_ok=True)
|
| 33 |
+
|
| 34 |
+
# 最後のクリーンアップ時刻を記録するファイル
|
| 35 |
+
self.cleanup_file = os.path.join(self.storage_path, "last_cleanup.json")
|
| 36 |
+
|
| 37 |
+
def get_or_create_session_id(self) -> str:
|
| 38 |
+
"""
|
| 39 |
+
セッションIDを取得または新規作成
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
セッションID(UUID4形式)
|
| 43 |
+
"""
|
| 44 |
+
try:
|
| 45 |
+
# 既存のセッションIDを確認
|
| 46 |
+
session_id = self._get_session_id_from_state()
|
| 47 |
+
|
| 48 |
+
if session_id and self._is_valid_session(session_id):
|
| 49 |
+
# 有効なセッションIDが存在する場合
|
| 50 |
+
self._update_session_access_time(session_id)
|
| 51 |
+
logger.info(f"既存セッションID使用: {session_id[:8]}...")
|
| 52 |
+
return session_id
|
| 53 |
+
|
| 54 |
+
# 新しいセッションIDを生成
|
| 55 |
+
session_id = str(uuid.uuid4())
|
| 56 |
+
self._create_new_session(session_id)
|
| 57 |
+
logger.info(f"新規セッションID生成: {session_id[:8]}...")
|
| 58 |
+
|
| 59 |
+
return session_id
|
| 60 |
+
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"セッションID取得エラー: {e}")
|
| 63 |
+
# フォールバック:一時的なセッションID
|
| 64 |
+
return str(uuid.uuid4())
|
| 65 |
+
|
| 66 |
+
def _get_session_id_from_state(self) -> Optional[str]:
|
| 67 |
+
"""
|
| 68 |
+
Streamlitの状態からセッションIDを取得
|
| 69 |
+
|
| 70 |
+
Returns:
|
| 71 |
+
セッションID(存在しない場合はNone)
|
| 72 |
+
"""
|
| 73 |
+
# Streamlitのセッション状態から取得
|
| 74 |
+
session_id = st.session_state.get('mari_session_id')
|
| 75 |
+
|
| 76 |
+
if session_id:
|
| 77 |
+
return session_id
|
| 78 |
+
|
| 79 |
+
# URLパラメータから取得を試行(フォールバック)
|
| 80 |
+
query_params = st.query_params
|
| 81 |
+
if 'session_id' in query_params:
|
| 82 |
+
session_id = query_params['session_id']
|
| 83 |
+
if self._is_valid_uuid(session_id):
|
| 84 |
+
return session_id
|
| 85 |
+
|
| 86 |
+
return None
|
| 87 |
+
|
| 88 |
+
def _is_valid_uuid(self, uuid_string: str) -> bool:
|
| 89 |
+
"""
|
| 90 |
+
UUIDの形式が正しいかチェック
|
| 91 |
+
|
| 92 |
+
Args:
|
| 93 |
+
uuid_string: チェックするUUID文字列
|
| 94 |
+
|
| 95 |
+
Returns:
|
| 96 |
+
有効な場合True
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
uuid.UUID(uuid_string, version=4)
|
| 100 |
+
return True
|
| 101 |
+
except (ValueError, TypeError):
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
def _is_valid_session(self, session_id: str) -> bool:
|
| 105 |
+
"""
|
| 106 |
+
セッションが有効かチェック
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
session_id: チェックするセッションID
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
有効な場合True
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 116 |
+
|
| 117 |
+
if not os.path.exists(session_file):
|
| 118 |
+
return False
|
| 119 |
+
|
| 120 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 121 |
+
session_data = json.load(f)
|
| 122 |
+
|
| 123 |
+
# 最終アクセス時刻をチェック
|
| 124 |
+
last_access = datetime.fromisoformat(session_data.get('last_access', ''))
|
| 125 |
+
expiry_time = last_access + timedelta(days=self.session_duration_days)
|
| 126 |
+
|
| 127 |
+
return datetime.now() < expiry_time
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.warning(f"セッション検証エラー: {e}")
|
| 131 |
+
return False
|
| 132 |
+
|
| 133 |
+
def _create_new_session(self, session_id: str) -> None:
|
| 134 |
+
"""
|
| 135 |
+
新しいセッションを作成
|
| 136 |
+
|
| 137 |
+
Args:
|
| 138 |
+
session_id: 新しいセッションID
|
| 139 |
+
"""
|
| 140 |
+
try:
|
| 141 |
+
# セッションデータを作成
|
| 142 |
+
session_data = {
|
| 143 |
+
'session_id': session_id,
|
| 144 |
+
'created_at': datetime.now().isoformat(),
|
| 145 |
+
'last_access': datetime.now().isoformat(),
|
| 146 |
+
'user_agent': self._get_user_agent(),
|
| 147 |
+
'ip_hash': self._get_ip_hash()
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
# セッションファイルに保存
|
| 151 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 152 |
+
with open(session_file, 'w', encoding='utf-8') as f:
|
| 153 |
+
json.dump(session_data, f, ensure_ascii=False, indent=2)
|
| 154 |
+
|
| 155 |
+
# Streamlitの状態に保存
|
| 156 |
+
st.session_state.mari_session_id = session_id
|
| 157 |
+
|
| 158 |
+
# Cookie設定のJavaScriptを生成(セキュア設定)
|
| 159 |
+
self._set_secure_cookie(session_id)
|
| 160 |
+
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(f"新規セッション作成エラー: {e}")
|
| 163 |
+
|
| 164 |
+
def _update_session_access_time(self, session_id: str) -> None:
|
| 165 |
+
"""
|
| 166 |
+
セッションの最終アクセス時刻を更新
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
session_id: 更新するセッションID
|
| 170 |
+
"""
|
| 171 |
+
try:
|
| 172 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 173 |
+
|
| 174 |
+
if os.path.exists(session_file):
|
| 175 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 176 |
+
session_data = json.load(f)
|
| 177 |
+
|
| 178 |
+
session_data['last_access'] = datetime.now().isoformat()
|
| 179 |
+
|
| 180 |
+
with open(session_file, 'w', encoding='utf-8') as f:
|
| 181 |
+
json.dump(session_data, f, ensure_ascii=False, indent=2)
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.warning(f"セッションアクセス時刻更新エラー: {e}")
|
| 185 |
+
|
| 186 |
+
def _set_secure_cookie(self, session_id: str) -> None:
|
| 187 |
+
"""
|
| 188 |
+
セキュアなCookieを設定
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
session_id: 設定するセッションID
|
| 192 |
+
"""
|
| 193 |
+
try:
|
| 194 |
+
# セキュアCookie設定のJavaScript
|
| 195 |
+
cookie_js = f"""
|
| 196 |
+
<script>
|
| 197 |
+
// セキュアCookieの設定
|
| 198 |
+
function setSecureCookie() {{
|
| 199 |
+
const sessionId = '{session_id}';
|
| 200 |
+
const expiryDays = {self.session_duration_days};
|
| 201 |
+
const expiryDate = new Date();
|
| 202 |
+
expiryDate.setTime(expiryDate.getTime() + (expiryDays * 24 * 60 * 60 * 1000));
|
| 203 |
+
|
| 204 |
+
// セキュア属性付きCookieを設定
|
| 205 |
+
let cookieString = `{self.cookie_name}=${{sessionId}}; expires=${{expiryDate.toUTCString()}}; path=/; SameSite=Strict`;
|
| 206 |
+
|
| 207 |
+
// HTTPS環境の場合はSecure属性を追加
|
| 208 |
+
if (location.protocol === 'https:') {{
|
| 209 |
+
cookieString += '; Secure';
|
| 210 |
+
}}
|
| 211 |
+
|
| 212 |
+
// HttpOnly属性は JavaScript では設定できないため、サーバーサイドで設定が必要
|
| 213 |
+
document.cookie = cookieString;
|
| 214 |
+
|
| 215 |
+
console.log('セッションCookie設定完了:', sessionId.substring(0, 8) + '...');
|
| 216 |
+
}}
|
| 217 |
+
|
| 218 |
+
// ページ読み込み時にCookieを設定
|
| 219 |
+
if (document.readyState === 'loading') {{
|
| 220 |
+
document.addEventListener('DOMContentLoaded', setSecureCookie);
|
| 221 |
+
}} else {{
|
| 222 |
+
setSecureCookie();
|
| 223 |
+
}}
|
| 224 |
+
</script>
|
| 225 |
+
"""
|
| 226 |
+
|
| 227 |
+
st.markdown(cookie_js, unsafe_allow_html=True)
|
| 228 |
+
|
| 229 |
+
except Exception as e:
|
| 230 |
+
logger.error(f"セキュアCookie設定エラー: {e}")
|
| 231 |
+
|
| 232 |
+
def _get_user_agent(self) -> str:
|
| 233 |
+
"""
|
| 234 |
+
ユーザーエージェントを取得(可能な場合)
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
ユーザーエージェント文字列
|
| 238 |
+
"""
|
| 239 |
+
try:
|
| 240 |
+
# Streamlitでは直接取得できないため、JavaScriptで取得
|
| 241 |
+
return "Streamlit-Client"
|
| 242 |
+
except Exception:
|
| 243 |
+
return "Unknown"
|
| 244 |
+
|
| 245 |
+
def _get_ip_hash(self) -> str:
|
| 246 |
+
"""
|
| 247 |
+
IPアドレスのハッシュを取得(プライバシー保護)
|
| 248 |
+
|
| 249 |
+
Returns:
|
| 250 |
+
IPアドレスのハッシュ
|
| 251 |
+
"""
|
| 252 |
+
try:
|
| 253 |
+
import hashlib
|
| 254 |
+
# 実際のIPアドレスは取得せず、セッション識別用のハッシュのみ
|
| 255 |
+
session_hash = hashlib.sha256(str(time.time()).encode()).hexdigest()[:16]
|
| 256 |
+
return session_hash
|
| 257 |
+
except Exception:
|
| 258 |
+
return "unknown"
|
| 259 |
+
|
| 260 |
+
def cleanup_expired_sessions(self) -> None:
|
| 261 |
+
"""
|
| 262 |
+
期限切れセッションのクリーンアップ
|
| 263 |
+
"""
|
| 264 |
+
try:
|
| 265 |
+
# 前回のクリーンアップ時刻をチェック
|
| 266 |
+
if not self._should_cleanup():
|
| 267 |
+
return
|
| 268 |
+
|
| 269 |
+
current_time = datetime.now()
|
| 270 |
+
cleaned_count = 0
|
| 271 |
+
|
| 272 |
+
# セッションファイルをスキャン
|
| 273 |
+
for filename in os.listdir(self.storage_path):
|
| 274 |
+
if filename.endswith('.json') and filename != 'last_cleanup.json':
|
| 275 |
+
session_file = os.path.join(self.storage_path, filename)
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 279 |
+
session_data = json.load(f)
|
| 280 |
+
|
| 281 |
+
last_access = datetime.fromisoformat(session_data.get('last_access', ''))
|
| 282 |
+
expiry_time = last_access + timedelta(days=self.session_duration_days)
|
| 283 |
+
|
| 284 |
+
if current_time > expiry_time:
|
| 285 |
+
os.remove(session_file)
|
| 286 |
+
cleaned_count += 1
|
| 287 |
+
logger.info(f"期限切れセッション削除: {filename}")
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.warning(f"セッションファイル処理エラー {filename}: {e}")
|
| 291 |
+
|
| 292 |
+
# クリーンアップ時刻を記録
|
| 293 |
+
self._update_cleanup_time()
|
| 294 |
+
|
| 295 |
+
if cleaned_count > 0:
|
| 296 |
+
logger.info(f"セッションクリーンアップ完了: {cleaned_count}件削除")
|
| 297 |
+
|
| 298 |
+
except Exception as e:
|
| 299 |
+
logger.error(f"セッションクリーンアップエラー: {e}")
|
| 300 |
+
|
| 301 |
+
def _should_cleanup(self) -> bool:
|
| 302 |
+
"""
|
| 303 |
+
クリーンアップが必要かチェック
|
| 304 |
+
|
| 305 |
+
Returns:
|
| 306 |
+
クリーンアップが必要な場合True
|
| 307 |
+
"""
|
| 308 |
+
try:
|
| 309 |
+
if not os.path.exists(self.cleanup_file):
|
| 310 |
+
return True
|
| 311 |
+
|
| 312 |
+
with open(self.cleanup_file, 'r', encoding='utf-8') as f:
|
| 313 |
+
cleanup_data = json.load(f)
|
| 314 |
+
|
| 315 |
+
last_cleanup = datetime.fromisoformat(cleanup_data.get('last_cleanup', ''))
|
| 316 |
+
next_cleanup = last_cleanup + timedelta(hours=self.cleanup_interval_hours)
|
| 317 |
+
|
| 318 |
+
return datetime.now() > next_cleanup
|
| 319 |
+
|
| 320 |
+
except Exception:
|
| 321 |
+
return True
|
| 322 |
+
|
| 323 |
+
def _update_cleanup_time(self) -> None:
|
| 324 |
+
"""
|
| 325 |
+
クリーンアップ時刻を更新
|
| 326 |
+
"""
|
| 327 |
+
try:
|
| 328 |
+
cleanup_data = {
|
| 329 |
+
'last_cleanup': datetime.now().isoformat()
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
with open(self.cleanup_file, 'w', encoding='utf-8') as f:
|
| 333 |
+
json.dump(cleanup_data, f, ensure_ascii=False, indent=2)
|
| 334 |
+
|
| 335 |
+
except Exception as e:
|
| 336 |
+
logger.warning(f"クリーンアップ時刻更新エラー: {e}")
|
| 337 |
+
|
| 338 |
+
def get_session_info(self, session_id: str) -> Dict[str, Any]:
|
| 339 |
+
"""
|
| 340 |
+
セッション情報を取得
|
| 341 |
+
|
| 342 |
+
Args:
|
| 343 |
+
session_id: セッションID
|
| 344 |
+
|
| 345 |
+
Returns:
|
| 346 |
+
セッション情報の辞書
|
| 347 |
+
"""
|
| 348 |
+
try:
|
| 349 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 350 |
+
|
| 351 |
+
if os.path.exists(session_file):
|
| 352 |
+
with open(session_file, 'r', encoding='utf-8') as f:
|
| 353 |
+
return json.load(f)
|
| 354 |
+
|
| 355 |
+
return {}
|
| 356 |
+
|
| 357 |
+
except Exception as e:
|
| 358 |
+
logger.error(f"セッション情報取得エラー: {e}")
|
| 359 |
+
return {}
|
| 360 |
+
|
| 361 |
+
def delete_session(self, session_id: str) -> bool:
|
| 362 |
+
"""
|
| 363 |
+
セッションを削除
|
| 364 |
+
|
| 365 |
+
Args:
|
| 366 |
+
session_id: 削除するセッションID
|
| 367 |
+
|
| 368 |
+
Returns:
|
| 369 |
+
削除成功時True
|
| 370 |
+
"""
|
| 371 |
+
try:
|
| 372 |
+
session_file = os.path.join(self.storage_path, f"{session_id}.json")
|
| 373 |
+
|
| 374 |
+
if os.path.exists(session_file):
|
| 375 |
+
os.remove(session_file)
|
| 376 |
+
logger.info(f"セッション削除: {session_id[:8]}...")
|
| 377 |
+
return True
|
| 378 |
+
|
| 379 |
+
return False
|
| 380 |
+
|
| 381 |
+
except Exception as e:
|
| 382 |
+
logger.error(f"セッション削除エラー: {e}")
|
| 383 |
+
return False
|