diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..d3a8d0def51f0b4ef4390ace50d3f21ef6d8f0f5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,52 @@ +# Git関連 +.git +.gitignore + +# Python関連 +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +env +pip-log.txt +pip-delete-this-directory.txt +.tox +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesis + +# OS関連 +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# IDE関連 +.vscode +.idea +*.swp +*.swo +*~ + +# ドキュメント +README.md +*.md + +# その他 +.env +.env.local +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..7578a2cb502315b226e658d97d9bd9b015852644 --- /dev/null +++ b/.env @@ -0,0 +1,24 @@ +# Hugging Face Spaces用環境変数設定 +GROQ_API_KEY=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 +GEMINI_API_KEY=cb9f7d85ba83dd7003e01031ff7988c48afe28914bdce8b882eddb70f1598bea +DEBUG_MODE=False +BATCH_SCHEDULE_HOURS=2,3,4 +MAX_DAILY_REQUESTS=1 +STORAGE_PATH=/tmp/letters.json +BACKUP_PATH=/tmp/backup +LOG_LEVEL=INFO +STREAMLIT_PORT=7860 +SESSION_TIMEOUT=3600 + +# 非同期手紙生成設定 +ASYNC_LETTER_ENABLED=true +GENERATION_TIMEOUT=300 +MAX_CONCURRENT_GENERATIONS=3 +GROQ_MODEL=compound-beta +TOGETHER_AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507-tput + +# 通常モード用システムプロンプト(1行形式) +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隠された内容は麻理の本当の気持ちや感情、表面的には言えない優しさや愛情、ツンデレの「デレ」部分、過去のトラウマからくる不安や恐れ、ユーザーへの本当の想いを表現してください。" + +# URAモード用システムプロンプト(1行形式) +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:(あんたのことが好きすぎて怖い...また捨てられたらどうしよう)]別に...あんたがいなくても平気だから。" \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..71b89d8c3e2033094dbc9cf15b2b382d9c52eb52 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +bijyutukann-yoru.jpg filter=lfs diff=lfs merge=lfs -text +image.png filter=lfs diff=lfs merge=lfs -text +jinnjya-hiru.jpg filter=lfs diff=lfs merge=lfs -text +kissa-hiru.jpg filter=lfs diff=lfs merge=lfs -text +maturi-yoru.jpg filter=lfs diff=lfs merge=lfs -text +ribinngu-hiru.jpg filter=lfs diff=lfs merge=lfs -text +ribinngu-yoru-on.jpg filter=lfs diff=lfs merge=lfs -text +sunahama-hiru.jpg filter=lfs diff=lfs merge=lfs -text +sunahama-yoru.jpg filter=lfs diff=lfs merge=lfs -text +sunahama-yuu.jpg filter=lfs diff=lfs merge=lfs -text +リビング2(夜・照明OFF).jpg filter=lfs diff=lfs merge=lfs -text +リビング2(夕方).jpg filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..ae86e9c9ce8f0a61eb9770cf3bb6c0e15fb2da36 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,60 @@ +FROM python:3.10-slim + +# 非rootユーザーを作成 +RUN useradd -m -u 1000 user +USER user +ENV HOME=/home/user \ + PATH=/home/user/.local/bin:$PATH + +# 作業ディレクトリを設定 +WORKDIR $HOME/app + +# システムの依存関係をインストール(rootで実行) +USER root +RUN apt-get update && apt-get install -y \ + build-essential \ + curl \ + software-properties-common \ + git \ + && rm -rf /var/lib/apt/lists/* + +# userに戻る +USER user + +# Pythonの依存関係をコピーしてインストール +COPY --chown=user requirements.txt . +RUN pip install --user --no-cache-dir -r requirements.txt + +# アプリケーションファイルをコピー +COPY --chown=user . . + +# Streamlit設定ディレクトリを作成 +RUN mkdir -p $HOME/.streamlit + +# Streamlit設定ファイルを作成 +RUN echo '\ +[server]\n\ +headless = true\n\ +port = 7860\n\ +address = "0.0.0.0"\n\ +enableCORS = false\n\ +enableXsrfProtection = false\n\ +\n\ +[theme]\n\ +primaryColor = "#FF6B6B"\n\ +backgroundColor = "#0E1117"\n\ +secondaryBackgroundColor = "#262730"\n\ +textColor = "#FAFAFA"\n\ +\n\ +[browser]\n\ +gatherUsageStats = false\n\ +' > $HOME/.streamlit/config.toml + +# ポート7860を公開(Hugging Face Spaces標準) +EXPOSE 7860 + +# ヘルスチェック +HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health + +# Streamlitアプリケーションを起動 +CMD ["streamlit", "run", "main_app.py", "--server.port=7860", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableXsrfProtection=false"] \ No newline at end of file diff --git a/README.md b/README.md index e2c769ea38925080c7f02d05f6436ce78b0a4408..412fac484dd85a88bfdcc41378187ad9eaa60e6b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,559 @@ --- -title: Mari Chat 3 -emoji: 🔥 +--- +title: 麻理チャット&手紙生成 統合アプリ +emoji: 🐕 colorFrom: pink -colorTo: gray -sdk: docker +colorTo: purple +sdk: streamlit +sdk_version: 1.28.0 +app_file: main_app.py pinned: false -license: mit --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# 🐕 麻理チャット&手紙生成 統合アプリ + +**麻理**という名前の感情豊かな少女型アンドロイドとの対話を楽しめる、高機能なAIチャットアプリケーションです。Together AIとGroqのAPIを使用し、リアルタイムチャットと非同期手紙生成の両方に対応しています。 + +## ✨ 主要機能 + +### 🐕 **ポチ機能(本音表示アシスタント)** +- 画面右下の犬のアシスタント「ポチ」が麻理の本音を察知 +- ワンクリックで全メッセージの本音を一括表示/非表示 +- ツンデレキャラクターの「デレ」部分を楽しめる +- レスポンシブ対応で様々な画面サイズに最適化 + +### 🔓 **セーフティ機能** +- 左サイドバーに統合されたセーフティ切り替えボタン +- 有効時は緑色、解除時は赤色で視覚的に分かりやすい表示 +- より大胆で直接的な表現を有効にするモード +- 環境変数で独自のプロンプトを設定可能 + +### ✉️ **非同期手紙生成** +- 指定した時間に自動的に手紙を生成・配信 +- テーマ選択機能(日常、恋愛、励まし、感謝など) +- バックグラウンド処理による効率的な生成 + +### 🎨 **高度なUI機能** +- **動的背景システム**: Groq APIによる会話内容解析で背景が自動変化(透明度調整済み) +- **好感度システム**: 左寒色から右暖色のグラデーションバーで関係性を視覚化 +- **メモリ管理**: 長期記憶と重要な会話の自動保存 +- **セッション分離**: 複数ユーザー間での完全な独立性 +- **レスポンシブデザイン**: タブレット・モバイル対応の最適化されたUI + +### 🛡️ **堅牢なシステム** +- **レート制限**: API使用量の適切な制御 +- **エラー回復**: 障害時の自動復旧機能 +- **セッション管理**: 強化されたセッション分離とデータ整合性 + +## 🚀 セットアップ方法 + +### Hugging Face Spacesでの実行(推奨) + +Hugging Face Spacesでは、セッション管理サーバーが自動的に起動されます。 + +#### 自動起動機能 +- アプリケーション起動時にFastAPIサーバーが自動で起動 +- セッション管理機能が完全に利用可能 +- Cookie-based認証によるセキュアなセッション管理 + +#### 環境変数設定 +Hugging Face Spacesの設定で以下の環境変数を設定してください: + +``` +TOGETHER_API_KEY=your_together_api_key_here +GROQ_API_KEY=your_groq_api_key_here +``` + +1. **リポジトリのインポート** + - このリポジトリをHugging Face Spacesにインポート + - または[デモサイト](https://huggingface.co/spaces/your-space-name)で直接体験 + +2. **Spaces設定** + - **SDK**: Streamlit + - **Python**: 3.10+ + - **Hardware**: CPU Basic(2GB RAM)以上推奨 + - **App File**: `spaces/main_app.py` + +3. **必須環境変数** + ```bash + TOGETHER_API_KEY=your_together_api_key_here + GROQ_API_KEY=your_groq_api_key_here + ``` + +4. **オプション環境変数** + ```bash + # 通常モード用カスタムプロンプト + SYSTEM_PROMPT_MARI=your_custom_prompt_here + + # セーフティ解除モード用プロンプト + SYSTEM_PROMPT_URA=your_ura_mode_prompt_here + + # デバッグモード有効化 + DEBUG_MODE=true + + # 手紙生成設定 + MAX_DAILY_REQUESTS=5 + BATCH_SCHEDULE_HOURS=2,3,4 + ASYNC_LETTER_ENABLED=true + ``` + +#### APIキーの取得方法 + +1. **Together AI** + - [Together AI](https://api.together.xyz/)にアクセス + - アカウント作成・ログイン + - APIキーを生成 + +2. **Groq** + - [Groq](https://console.groq.com/)にアクセス + - アカウント作成・ログイン + - APIキーを生成 + +3. **Hugging Face Spaces設定** + - Hugging Face Spacesの「Settings」→「Variables and secrets」で設定 + +### ローカル環境での実行 + +1. **リポジトリをクローン** + ```bash + git clone + cd mari-chat-app + ``` + +2. **依存関係をインストール** + ```bash + pip install -r requirements.txt + ``` + +3. **環境変数を設定** + `.env`ファイルを作成: + ```bash + # 必須設定 + TOGETHER_API_KEY=your_together_api_key_here + GROQ_API_KEY=your_groq_api_key_here + + # オプション設定 + SYSTEM_PROMPT_MARI=your_custom_prompt_here + SYSTEM_PROMPT_URA=your_ura_mode_prompt_here + DEBUG_MODE=true + + # 手紙機能設定 + MAX_DAILY_REQUESTS=5 + STORAGE_PATH=/tmp/letters.json + BATCH_SCHEDULE_HOURS=2,3,4 + ASYNC_LETTER_ENABLED=true + ``` + +4. **アプリケーションを実行** + ```bash + streamlit run main_app.py + ``` + +5. **ブラウザでアクセス** + - 自動的にブラウザが開きます + - 手動の場合: `http://localhost:8501` + +### Dockerでの実行 + +1. **Dockerイメージをビルド** + ```bash + docker build -t mari-chat-app . + ``` + +2. **コンテナを実行** + ```bash + docker run -p 8501:8501 \ + -e TOGETHER_API_KEY=your_api_key_here \ + -e DEBUG_MODE=true \ + mari-chat-app + ``` + +3. **docker-composeを使用** + ```bash + docker-compose up -d + ``` + +4. **環境変数ファイルを使用** + ```bash + docker run -p 8501:8501 --env-file spaces/.env mari-chat-app + ``` + +## 📖 使用方法 + +### 🎭 チャット機能 + +#### 基本的な使い方 +1. **メッセージ入力**: 画面下部のテキスト入力欄にメッセージを入力 +2. **送信**: 送信ボタンをクリック +3. **応答確認**: 麻理からの応答が表示されます +4. **本音表示**: 画面右下のポチ(🐕)をクリックして本音を確認 + +#### ポチ機能の使い方 +- **🐕 ポチ**: 画面右下に固定配置された犬のアシスタント +- **ワンクリック**: ポチをクリックすると全メッセージの本音が一括表示 +- **吹き出し**: 「ポチは麻理の本音を察知したようだ・・・」の可愛い吹き出し(ポチボタンの上に左寄せ配置) +- **背景色変化**: 本音表示時は暖色系の背景に変化 +- **レスポンシブ**: 画面サイズに応じて適切にサイズ調整(デスクトップ・タブレット・モバイル対応) + +#### セーフティ機能 +- **🔒/🔓ボタン**: 左サイドバー最上部のセーフティ切り替えボタン +- **色分け表示**: 有効時は緑色、解除時は赤色で一目で分かる +- **URAプロンプト**: `SYSTEM_PROMPT_URA`環境変数で独自のセーフティ解除モードプロンプトを設定可能 +- **表現変化**: より大胆で直接的な表現が有効になる +- **本音への影響**: セーフティ解除時はより踏み込んだ感情表現 + +### ✉️ 手紙機能 + +#### 手紙のリクエスト +1. **「手紙を受け取る」タブ**を選択 +2. **テーマ選択**: 日常、恋愛、励まし、感謝、お疲れ様、おやすみから選択 +3. **配信時間設定**: 希望する受け取り時間を指定 +4. **リクエスト送信**: 「手紙をリクエスト」ボタンをクリック + +#### 手紙の受け取り +- **自動生成**: 指定時間にバックグラウンドで生成 +- **通知**: 新しい手紙が届くとチャットで通知 +- **履歴確認**: 過去の手紙履歴を確認可能 + +### 🎛️ サイドバー機能 + +#### セーフティ機能(最上部) +- **🔒/🔓ボタン**: セーフティの有効/無効を色で表示 +- **緑色**: セーフティ有効(通常モード、`SYSTEM_PROMPT_MARI`使用) +- **赤色**: セーフティ解除(URAモード、`SYSTEM_PROMPT_URA`使用) + +#### ステータス表示 +- **好感度**: 白色の文字で表示される好感度(0-100)と左寒色→右暖色のグラデーションバー +- **関係性**: 敵対→中立→好意→親密→最接近の段階表示(ステージ番号なし) +- **現在のシーン**: 背景シーンの名前 + +#### 設定機能 +- **🔄会話をリセット**: 大きな文字で表示される会話リセットボタン +- **🛠️デバッグ情報**: 詳細なシステム状態を表示(DEBUG_MODE=true時) + +### 🎨 高度な機能 + +#### 自動シーン変更 +- **Groq API検出**:最新推論モデルによる高精度な会話内容解析 +- **フォールバック機能**: API失敗時はキーワードベースの検出に自動切り替え +- **キーワード検出**: 「美術館」「カフェ」「神社」「夜」などの場所・時間を話題にすると自動変化 +- **背景切り替え**: 会話内容に応じて背景画像が動的に変化(常時更新、スキップ機能なし) +- **雰囲気演出**: シーンに合わせた色調とエフェクト + +#### メモリ管理 +- **長期記憶**: 重要な会話内容を自動的に記憶 +- **要約機能**: 長い会話履歴を効率的に圧縮 +- **特別な記憶**: 印象的な出来事を特別に保存 + +#### セッション分離 +- **ユーザー独立**: 各ユーザーの会話は完全に分離 +- **データ保護**: 他のユーザーの操作による影響なし +- **整合性チェック**: セッション状態の自動検証と復旧 + +## 🎨 シーン一覧 + +| シーン名 | 説明 | 背景画像 | トリガーワード | +|---------|------|----------|---------------| +| `default` | デフォルトの部屋 | 温かみのある室内 | 部屋、家 | +| `room_night` | 夜の部屋 | 夜景が見える室内 | 夜、寝る | +| `beach_sunset` | 夕暮れのビーチ | 美しい夕日のビーチ | ビーチ、海、夕日 | +| `festival_night` | 夜のお祭り | 賑やかな夜祭り | お祭り、花火、屋台 | +| `shrine_day` | 昼間の神社 | 静寂な神社の境内 | 神社、お参り、鳥居 | +| `cafe_afternoon` | 午後のカフェ | 落ち着いたカフェ | カフェ、コーヒー | +| `art_museum_night` | 夜の美術館 | 幻想的な美術館 | 美術館、アート、絵画 | + +### シーン変更の仕組み +- **自動検出**: 会話内容から場所に関するキーワードを検出 +- **スムーズ遷移**: 1.5秒のフェードイン・アウト効果 +- **文脈適応**: 会話の流れに自然に溶け込む背景変化 +- **美術館検出**: 美術館、アート、絵画、彫刻などのキーワードで夜の美術館シーンに変更 + +## 🔧 技術仕様 + +### API情報 +- **プロバイダー**: [Together AI](https://api.together.xyz/) と [Groq](https://console.groq.com/) +- **使用モデル**: + - Together AI: `Qwen/Qwen3-235B-A22B-Instruct-2507-tput`(対話生成) + - Groq: `llama-3.1-70b-versatile`(シーン検出・手紙生成) +- **レート制限**: 適応的制限により安定した動作 +- **フォールバック**: API障害時は固定応答で継続動作 +- **シーン検出**: Groq API優先、失敗時はキーワードベース検出 + +### システム要件 +- **Python**: 3.8以上(3.10推奨) +- **メモリ**: 最小1GB、推奨2GB以上 +- **ストレージ**: 100MB以上の空き容量 +- **ネットワーク**: インターネット接続必須 + +### パフォーマンス +- **応答時間**: 通常2-5秒 +- **同時接続**: 複数ユーザー対応 +- **メモリ効率**: 自動圧縮により長期間の安定動作 +- **セッション管理**: 強化された分離機能 + +## 🔧 トラブルシューティング + +### よくある問題と解決方法 + +#### 🔑 APIキー関連 +**問題**: APIキーエラーが発生する +**解決方法**: +- `TOGETHER_API_KEY`が正しく設定されているか確認 +- [Together AI](https://api.together.xyz/)でAPIキーの有効性を確認 +- 環境変数の再設定後、アプリを再起動 + +#### 🚀 起動・動作問題 +**問題**: アプリが起動しない +**解決方法**: +- 依存関係の再インストール: `pip install -r spaces/requirements.txt` +- Pythonバージョン確認: `python --version`(3.8以上必須) +- ポート8501が使用中でないか確認 + +**問題**: 応答が生成されない +**解決方法**: +- インターネット接続を確認 +- APIの利用制限状況を確認 +- デバッグモードでエラーログを確認 + +#### 🐕 ポチ機能問題 +**問題**: ポチが表示されない、または動作しない +**解決方法**: +- ブラウザのキャッシュをクリア +- 画面右下にポチが固定表示されているか確認 +- 会話をリセットして再試行 +- デバッグモードでHIDDEN形式の検出状況を確認 + +#### 🎨 背景画像問題 +**問題**: 背景画像が見えない、または白い背景になっている +**解決方法**: +- アプリケーションを再起動(背景更新スキップ機能は無効化済み) +- ブラウザのキャッシュをクリア +- 「美術館」「夜」「カフェ」などのキーワードでシーン変更をテスト +- デバッグモードで右上の「🖼️ 背景適用済」表示を確認 +- タブやメッセージエリアは白い半透明背景で保護されているのが正常 + +#### 💾 メモリ・セッション問題 +**問題**: メモリエラーや会話履歴の問題 +**解決方法**: +- サイドバーの「🔄会話をリセット」を実行 +- ブラウザのキャッシュとCookieをクリア +- アプリの再起動 + +### 🛠️ デバッグ機能 + +#### デバッグモードの有効化 +1. 環境変数で`DEBUG_MODE=true`を設定 +2. または、サイドバーの「🛠️デバッグ情報」を展開 + +#### 確認できる情報 +- **セッション分離状態**: ユーザー独立性の確認 +- **メモリ統計**: 使用量と圧縮状況 +- **API応答ログ**: リクエスト・レスポンスの詳細 +- **ポチ機能統計**: HIDDEN形式の検出状況 + +#### ログファイルの場所 +- **Streamlitログ**: コンソール出力 +- **アプリケーションログ**: Python loggingモジュール +- **セッション情報**: デバッグモードで表示 + +## 👨‍💻 開発者向け情報 + +### プロジェクト構造 + +``` +MariChat2-AI--main/ +├── main_app.py # 統合メインアプリケーション +├── components_chat_interface.py # チャット機能(ポチ機能付き) +├── components_dog_assistant.py # ポチ(犬)アシスタント +├── session_manager.py # セッション分離管理 +├── core_dialogue.py # 対話生成(HIDDEN形式対応) +├── core_sentiment.py # 感情分析・好感度システム +├── core_scene_manager.py # 動的背景システム +├── core_memory_manager.py # 長期記憶管理 +├── core_rate_limiter.py # レート制限 +├── letter_*.py # 非同期手紙生成システム +├── async_*.py # 非同期処理関連 +├── streamlit_styles.css # カスタムCSS(ポチ機能含む) +├── requirements.txt # 依存関係 +├── requirements_session.txt # セッション管理用依存関係 +├── .env # 環境変数設定 +├── session_data/ # セッションデータ保存 +├── tmp/ # 一時ファイル +├── push/ # プッシュ通知関連 +├── Dockerfile # Docker設定 +├── .dockerignore # Docker除外設定 +└── README.md # このファイル +``` + +### 🐕 ポチ機能の実装 + +#### 核となる仕組み +```python +# HIDDEN形式の検出 +pattern = r'\[HIDDEN:(.*?)\](.*)' +has_hidden, visible, hidden = detect_hidden_content(message) + +# 画面右下への固定配置(左寄せ調整済み) +CSS: position: fixed; bottom: 20px; right: 20px; transform: translateX(-50px); + +# 全メッセージの一括制御 +st.session_state.show_all_hidden = not st.session_state.show_all_hidden +``` + +#### カスタマイズポイント +- **プロンプト**: `SYSTEM_PROMPT_MARI`/`SYSTEM_PROMPT_URA`で隠された真実の生成を制御 +- **レスポンシブ**: CSSメディアクエリで画面サイズ対応(デスクトップ・タブレット・モバイル) +- **検出ロジック**: `_detect_hidden_content()`で形式を変更可能 +- **ポチの見た目**: `components_dog_assistant.py`でデザイン調整 +- **吹き出し位置**: 左寄せ配置でポチボタンの真上に表示 + +### ✉️ 手紙機能の実装 + +#### 非同期処理アーキテクチャ +```python +# バックグラウンド生成 +async def generate_letter_async(theme, user_id): + # 非同期でAI生成 + +# スケジューラー +batch_scheduler.schedule_generation(user_id, theme, delivery_time) + +# ストレージ管理 +async_storage.save_letter(letter_data) +``` + +#### 手紙生成システム +- **非同期処理**: `async_letter_app.py`でバックグラウンド生成 +- **設定管理**: `async_config_setup.py`で環境設定 +- **レート制限**: `async_rate_limiter.py`でAPI使用量制御 +- **ストレージ**: `async_storage_manager.py`でデータ永続化 + +### 🛡️ セッション分離システム + +#### 強化された分離機能 +```python +class SessionManager: + def __init__(self): + self.session_id = id(st.session_state) + self.user_id = None + + def validate_session_integrity(self): + # セッション整合性チェック +``` + +### カスタマイズガイド + +#### 新しいシーンの追加 +1. `core_scene_manager.py`の`theme_urls`辞書に追加 +2. 背景画像URLを設定 +3. Groqプロンプトの`scenes_description`に説明を追加 +4. フォールバック用の`keyword_scene_map`にトリガーワードを定義 + +#### プロンプトのカスタマイズ +```bash +# 通常モード +SYSTEM_PROMPT_MARI="あなたのカスタムプロンプト..." + +# セーフティ解除モード +SYSTEM_PROMPT_URA="より大胆なプロンプト..." +``` + +#### UIスタイルの変更 +- `streamlit_styles.css`でカラーテーマ、アニメーション、レイアウトを調整 +- CSS変数を使用した統一的なデザインシステム +- ポチの固定配置とレスポンシブ対応 + +### テスト機能 + +#### 提供されるテストスクリプト +- `test_mask_functionality.py`: ポチ機能の動作確認 +- `test_multiple_hidden_fixed.py`: 複数HIDDEN形式の処理確認 +- `test_hidden_format_issue.py`: HIDDEN形式の問題診断 +- `debug_session_state.py`: セッション状態のデバッグ + +#### テスト実行方法 +```bash +python test_mask_functionality.py +python test_multiple_hidden_fixed.py +``` + +## 🤝 貢献・開発参加 + +### バグ報告・機能提案 +- **Issues**: GitHubのIssuesでバグ報告や機能提案をお願いします +- **Discussion**: 新機能のアイデアや改善案の議論 +- **Pull Request**: コード改善やバグ修正のPRを歓迎します + +### 開発ガイドライン +1. **コードスタイル**: PEP 8に準拠 +2. **テスト**: 新機能には対応するテストを追加 +3. **ドキュメント**: 変更内容をREADMEに反映 +4. **コミット**: 明確で説明的なコミットメッセージ + +### 開発環境のセットアップ +```bash +# 開発用依存関係のインストール +pip install -r requirements.txt + +# テストの実行 +python -m pytest tests/ + +# コードフォーマット +black . +flake8 . +``` + +## 📄 ライセンス + +このプロジェクトは**MITライセンス**の下で公開されています。 + +## 🙏 謝辞 + +- **Together AI**: 高品質なLLM APIの提供 +- **Streamlit**: 直感的なWebアプリフレームワーク +- **コミュニティ**: バグ報告や機能提案をいただいた皆様 + +## 📞 サポート・連絡先 + +- **GitHub Issues**: バグ報告・機能提案 +- **Discussions**: 一般的な質問・議論 +- **Email**: 重要な問題やセキュリティ関連 + +## 🔄 更新履歴 + +### v2.1.0 (最新) +- 🐕 **ポチ機能**: 画面右下の犬アシスタントによる本音一括表示(吹き出し位置を左寄せに調整) +- 🔓 **セーフティ統合**: 左サイドバーへの統合と色分け表示(URAプロンプト対応) +- 🎨 **シーン検出強化**: Groq API優先、フォールバック機能付きの高精度シーン変更 +- 📊 **好感度バー改善**: 左寒色→右暖色のグラデーション、動的塗りつぶし表示 +- 🎨 **背景システム改善**: スキップ機能無効化による確実な背景表示 +- 📱 **レスポンシブ対応**: 様々な画面サイズに最適化 +- ✉️ **非同期手紙生成**: バックグラウンド手紙生成 +- 🛡️ **セッション分離強化**: マルチユーザー対応改善 + +### v2.0.0 +- 🎭 **マスク機能**: 隠された真実の表示機能(現在はポチ機能に統合) +- 🔓 **セーフティ解除モード**: より大胆な表現モード +- ✉️ **非同期手紙生成**: バックグラウンド手紙生成 +- 🛡️ **セッション分離強化**: マルチユーザー対応改善 +- 🎨 **UI再設計**: モダンで直感的なインターフェース + +### v1.0.0 +- 基本的なチャット機能 +- 好感度システム +- 動的背景変更 +- メモリ管理機能 + +--- + +
+ +**🐕 Made with ❤️ using Streamlit & Together AI 🐕** + +*麻理とポチとの特別な時間をお楽しみください* + +[![Streamlit](https://img.shields.io/badge/Streamlit-FF4B4B?style=for-the-badge&logo=streamlit&logoColor=white)](https://streamlit.io/) +[![Together AI](https://img.shields.io/badge/Together%20AI-000000?style=for-the-badge&logo=ai&logoColor=white)](https://together.ai/) +[![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org/) + +
\ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..105c7adff240b13249b254d77fdaf62ca973bc5e --- /dev/null +++ b/__init__.py @@ -0,0 +1,7 @@ +""" +非同期手紙生成アプリ(麻理AI) +Asynchronous Letter Generation App (Mari AI) +""" + +__version__ = "1.0.0" +__author__ = "Mari AI Team" \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..71c837280afb86695f5fabeff68b3ec3569fb0e2 --- /dev/null +++ b/app.py @@ -0,0 +1,108 @@ +""" +メインアプリケーションモジュール +Main application module +""" + +import asyncio +import sys +from pathlib import Path +from letter_config import Config +from letter_logger import get_app_logger + +# プロジェクトルートをPythonパスに追加 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +logger = get_app_logger() + +class LetterApp: + """非同期手紙生成アプリケーションのメインクラス""" + + def __init__(self): + """アプリケーションを初期化""" + self.config = Config() + self.logger = logger + self._initialized = False + + async def initialize(self) -> bool: + """ + アプリケーションを初期化する + + Returns: + 初期化が成功したかどうか + """ + try: + self.logger.info("アプリケーションを初期化中...") + + # 設定の妥当性をチェック + if not self.config.validate_config(): + self.logger.error("設定の検証に失敗しました") + return False + + # ストレージディレクトリを作成 + await self._setup_storage_directories() + + # ログディレクトリを作成 + await self._setup_log_directories() + + self._initialized = True + self.logger.info("アプリケーションの初期化が完了しました") + return True + + except Exception as e: + self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}") + return False + + async def _setup_storage_directories(self): + """ストレージディレクトリを作成""" + storage_dir = Path(self.config.STORAGE_PATH).parent + backup_dir = Path(self.config.BACKUP_PATH) + + storage_dir.mkdir(parents=True, exist_ok=True) + backup_dir.mkdir(parents=True, exist_ok=True) + + self.logger.info(f"ストレージディレクトリを作成: {storage_dir}") + self.logger.info(f"バックアップディレクトリを作成: {backup_dir}") + + async def _setup_log_directories(self): + """ログディレクトリを作成""" + if not self.config.DEBUG_MODE: + log_dir = Path("/mnt/data/logs") + log_dir.mkdir(parents=True, exist_ok=True) + self.logger.info(f"ログディレクトリを作成: {log_dir}") + + def is_initialized(self) -> bool: + """アプリケーションが初期化されているかチェック""" + return self._initialized + + def get_config(self) -> Config: + """設定オブジェクトを取得""" + return self.config + +# グローバルアプリケーションインスタンス +app_instance = None + +async def get_app() -> LetterApp: + """アプリケーションインスタンスを取得(シングルトン)""" + global app_instance + + if app_instance is None: + app_instance = LetterApp() + await app_instance.initialize() + + return app_instance + +def run_app(): + """アプリケーションを実行""" + async def main(): + app = await get_app() + if app.is_initialized(): + logger.info("アプリケーションが正常に起動しました") + else: + logger.error("アプリケーションの起動に失敗しました") + sys.exit(1) + + asyncio.run(main()) + +if __name__ == "__main__": + run_app() \ No newline at end of file diff --git a/async_config_setup.py b/async_config_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..09f842f8d698e14c25f7dbf21e0b1d78d6661b59 --- /dev/null +++ b/async_config_setup.py @@ -0,0 +1,143 @@ +""" +非同期手紙生成システムの設定初期化 +Async Letter Generation System Configuration Setup +""" + +import os +import sys +from pathlib import Path +from letter_config import Config +from letter_logger import get_app_logger + +logger = get_app_logger() + +def initialize_config() -> bool: + """ + 設定を初期化し、必要なディレクトリを作成する + + Returns: + 初期化が成功したかどうか + """ + try: + # 設定の妥当性をチェック + if not Config.validate_config(): + logger.error("設定の検証に失敗しました") + return False + + # 必要なディレクトリを作成 + storage_path = Path(Config.STORAGE_PATH) + storage_path.parent.mkdir(parents=True, exist_ok=True) + + backup_path = Path(Config.BACKUP_PATH) + backup_path.mkdir(parents=True, exist_ok=True) + + # ログディレクトリを作成(本番環境用) + if not Config.DEBUG_MODE: + log_dir = Path("/tmp") + log_dir.mkdir(parents=True, exist_ok=True) + + logger.info("設定初期化が完了しました") + logger.info(f"ストレージパス: {Config.STORAGE_PATH}") + logger.info(f"バックアップパス: {Config.BACKUP_PATH}") + logger.info(f"デバッグモード: {Config.DEBUG_MODE}") + logger.info(f"バッチ処理時刻: {Config.BATCH_SCHEDULE_HOURS}") + + return True + + except Exception as e: + logger.error(f"設定初期化エラー: {e}") + return False + +def check_api_keys() -> bool: + """ + API キーの存在をチェックする + + Returns: + API キーが設定されているかどうか + """ + try: + missing_keys = [] + + if not Config.GROQ_API_KEY: + missing_keys.append("GROQ_API_KEY") + + if not Config.GEMINI_API_KEY: + missing_keys.append("GEMINI_API_KEY") + + if missing_keys: + logger.error(f"必要なAPI キーが設定されていません: {', '.join(missing_keys)}") + return False + + logger.info("API キーの確認が完了しました") + return True + + except Exception as e: + logger.error(f"API キー確認エラー: {e}") + return False + +def setup_environment() -> bool: + """ + 環境をセットアップする + + Returns: + セットアップが成功したかどうか + """ + try: + # 設定を初期化 + if not initialize_config(): + return False + + # API キーをチェック + if not check_api_keys(): + return False + + # Hugging Face Spaces 固有の設定 + if os.getenv("SPACE_ID"): + logger.info(f"Hugging Face Spaces環境で実行中: {os.getenv('SPACE_ID')}") + + # Spaces用の追加設定があればここに記述 + + logger.info("環境セットアップが完了しました") + return True + + except Exception as e: + logger.error(f"環境セットアップエラー: {e}") + return False + +def get_system_info() -> dict: + """ + システム情報を取得する + + Returns: + システム情報の辞書 + """ + return { + "python_version": sys.version, + "storage_path": Config.STORAGE_PATH, + "backup_path": Config.BACKUP_PATH, + "debug_mode": Config.DEBUG_MODE, + "batch_hours": Config.BATCH_SCHEDULE_HOURS, + "max_daily_requests": Config.MAX_DAILY_REQUESTS, + "generation_timeout": Config.GENERATION_TIMEOUT, + "max_concurrent_generations": Config.MAX_CONCURRENT_GENERATIONS, + "groq_model": Config.GROQ_MODEL, + "Together_model": Config.TOGETHER_API_MODEL, + "available_themes": Config.AVAILABLE_THEMES, + "space_id": os.getenv("SPACE_ID", "local"), + "streamlit_port": Config.STREAMLIT_PORT + } + +if __name__ == "__main__": + # スクリプトとして実行された場合の設定確認 + print("非同期手紙生成システム設定確認") + print("=" * 50) + + if setup_environment(): + print("✅ 環境セットアップ成功") + + system_info = get_system_info() + for key, value in system_info.items(): + print(f"{key}: {value}") + else: + print("❌ 環境セットアップ失敗") + sys.exit(1) \ No newline at end of file diff --git a/async_letter_app.py b/async_letter_app.py new file mode 100644 index 0000000000000000000000000000000000000000..1ddcef8f55ddd423e36f03ec4bfa57074d148586 --- /dev/null +++ b/async_letter_app.py @@ -0,0 +1,110 @@ +""" +メインアプリケーションモジュール +Main application module +""" + +import asyncio +import sys +from pathlib import Path + +# このファイルが依存している他のモジュールをインポート +from letter_config import Config +from letter_logger import get_app_logger + +# このモジュール用のロガーを取得 +logger = get_app_logger() + +class LetterApp: + """非同期手紙生成アプリケーションのメインクラス""" + + def __init__(self): + """アプリケーションを初期化""" + self.config = Config() + self.logger = logger + self._initialized = False + + async def initialize(self) -> bool: + """ + アプリケーションを初期化する + + Returns: + 初期化が成功したかどうか + """ + try: + self.logger.info("アプリケーションを初期化中...") + + # 設定の妥当性をチェック + if not self.config.validate_config(): + self.logger.error("設定の検証に失敗しました") + return False + + # ストレージディレクトリを作成 + await self._setup_storage_directories() + + # ログディレクトリを作成 + await self._setup_log_directories() + + self._initialized = True + self.logger.info("アプリケーションの初期化が完了しました") + return True + + except Exception as e: + self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}") + return False + + async def _setup_storage_directories(self): + """ストレージディレクトリを作成""" + storage_dir = Path(self.config.STORAGE_PATH).parent + backup_dir = Path(self.config.BACKUP_PATH) + + storage_dir.mkdir(parents=True, exist_ok=True) + backup_dir.mkdir(parents=True, exist_ok=True) + + self.logger.info(f"ストレージディレクトリを作成: {storage_dir}") + self.logger.info(f"バックアップディレクトリを作成: {backup_dir}") + + async def _setup_log_directories(self): + """ログディレクトリを作成""" + if not self.config.DEBUG_MODE: + # ログファイルは個別のロガーで設定されるため、 + # ここでは共通の親ディレクトリを作成する例 + log_dir = Path("/tmp/logs") # 例: /tmp/batch.log などの親 + log_dir.mkdir(parents=True, exist_ok=True) + self.logger.info(f"ログディレクトリを作成: {log_dir}") + + def is_initialized(self) -> bool: + """アプリケーションが初期化されているかチェック""" + return self._initialized + + def get_config(self) -> Config: + """設定オブジェクトを取得""" + return self.config + +# グローバルアプリケーションインスタンス(シングルトンパターン) +app_instance = None + +async def get_app() -> LetterApp: + """アプリケーションインスタンスを取得(シングルトン)""" + global app_instance + + if app_instance is None: + app_instance = LetterApp() + await app_instance.initialize() + + return app_instance + +def run_app(): + """アプリケーションを実行(テスト用)""" + async def main(): + app = await get_app() + if app.is_initialized(): + logger.info("アプリケーションが正常に起動しました") + else: + logger.error("アプリケーションの起動に失敗しました") + sys.exit(1) + + asyncio.run(main()) + +# このファイルが直接実行された場合にテスト用の起動処理を行う +if __name__ == "__main__": + run_app() \ No newline at end of file diff --git a/async_rate_limiter.py b/async_rate_limiter.py new file mode 100644 index 0000000000000000000000000000000000000000..6f3b8789254a7b25f2ddba751a064b8ec528ec53 --- /dev/null +++ b/async_rate_limiter.py @@ -0,0 +1,390 @@ +""" +非同期レート制限管理クラス +1日1回制限とAPI呼び出し制限を管理し、 +デバッグモード時の制限緩和機能を提供します。 +""" + +import asyncio +import os +from datetime import datetime, timedelta +from typing import Dict, Any, Optional, Tuple +import logging + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class RateLimitError(Exception): + """レート制限エラー""" + pass + + +class AsyncRateLimitManager: + """非同期レート制限管理クラス""" + + def __init__(self, storage_manager, max_requests: int = 1): + self.storage = storage_manager + + # 設定値(環境変数から取得、デフォルト値あり) + self.max_daily_requests = int(os.getenv("MAX_DAILY_REQUESTS", "1")) + self.max_api_calls_per_day = int(os.getenv("MAX_API_CALLS_PER_DAY", "10")) + self.debug_mode = os.getenv("DEBUG_MODE", "false").lower() == "true" + + # デバッグモード時の制限緩和 + if self.debug_mode: + self.max_daily_requests = int(os.getenv("DEBUG_MAX_DAILY_REQUESTS", "10")) + self.max_api_calls_per_day = int(os.getenv("DEBUG_MAX_API_CALLS", "100")) + logger.info("デバッグモードが有効です。制限が緩和されています") + + logger.info(f"レート制限設定 - 1日のリクエスト上限: {self.max_daily_requests}, API呼び出し上限: {self.max_api_calls_per_day}") + + async def check_daily_request_limit(self, user_id: str) -> Tuple[bool, Dict[str, Any]]: + """1日のリクエスト制限をチェック""" + try: + user_data = await self.storage.get_user_data(user_id) + today = datetime.now().strftime("%Y-%m-%d") + + # 今日のリクエスト数を取得 + daily_requests = user_data["rate_limits"]["daily_requests"] + today_requests = daily_requests.get(today, 0) + + # 制限チェック + is_allowed = today_requests < self.max_daily_requests + + limit_info = { + "today_requests": today_requests, + "max_requests": self.max_daily_requests, + "remaining": max(0, self.max_daily_requests - today_requests), + "reset_time": self._get_next_reset_time(), + "debug_mode": self.debug_mode + } + + if not is_allowed: + logger.warning(f"ユーザー {user_id} の1日のリクエスト制限に達しました ({today_requests}/{self.max_daily_requests})") + + return is_allowed, limit_info + + except Exception as e: + logger.error(f"1日のリクエスト制限チェックエラー: {e}") + # エラー時は制限を適用 + return False, {"error": str(e)} + + async def check_api_call_limit(self, user_id: str) -> Tuple[bool, Dict[str, Any]]: + """API呼び出し制限をチェック""" + try: + user_data = await self.storage.get_user_data(user_id) + today = datetime.now().strftime("%Y-%m-%d") + + # 今日のAPI呼び出し数を取得 + api_calls = user_data["rate_limits"]["api_calls"] + today_calls = api_calls.get(today, 0) + + # 制限チェック + is_allowed = today_calls < self.max_api_calls_per_day + + limit_info = { + "today_calls": today_calls, + "max_calls": self.max_api_calls_per_day, + "remaining": max(0, self.max_api_calls_per_day - today_calls), + "reset_time": self._get_next_reset_time(), + "debug_mode": self.debug_mode + } + + if not is_allowed: + logger.warning(f"ユーザー {user_id} のAPI呼び出し制限に達しました ({today_calls}/{self.max_api_calls_per_day})") + + return is_allowed, limit_info + + except Exception as e: + logger.error(f"API呼び出し制限チェックエラー: {e}") + # エラー時は制限を適用 + return False, {"error": str(e)} + + async def record_request(self, user_id: str) -> None: + """リクエストを記録""" + try: + user_data = await self.storage.get_user_data(user_id) + today = datetime.now().strftime("%Y-%m-%d") + + # 今日のリクエスト数を増加 + if "daily_requests" not in user_data["rate_limits"]: + user_data["rate_limits"]["daily_requests"] = {} + + user_data["rate_limits"]["daily_requests"][today] = \ + user_data["rate_limits"]["daily_requests"].get(today, 0) + 1 + + # プロファイルの最終リクエスト日を更新 + user_data["profile"]["last_request"] = today + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"ユーザー {user_id} のリクエストを記録しました") + + except Exception as e: + logger.error(f"リクエスト記録エラー: {e}") + raise RateLimitError(f"リクエストの記録に失敗しました: {e}") + + async def record_api_call(self, user_id: str, api_type: str = "general") -> None: + """API呼び出しを記録""" + try: + user_data = await self.storage.get_user_data(user_id) + today = datetime.now().strftime("%Y-%m-%d") + + # 今日のAPI呼び出し数を増加 + if "api_calls" not in user_data["rate_limits"]: + user_data["rate_limits"]["api_calls"] = {} + + user_data["rate_limits"]["api_calls"][today] = \ + user_data["rate_limits"]["api_calls"].get(today, 0) + 1 + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"ユーザー {user_id} のAPI呼び出し ({api_type}) を記録しました") + + except Exception as e: + logger.error(f"API呼び出し記録エラー: {e}") + raise RateLimitError(f"API呼び出しの記録に失敗しました: {e}") + + async def get_user_limits_status(self, user_id: str) -> Dict[str, Any]: + """ユーザーの制限状況を取得""" + try: + # リクエスト制限の確認 + request_allowed, request_info = await self.check_daily_request_limit(user_id) + + # API呼び出し制限の確認 + api_allowed, api_info = await self.check_api_call_limit(user_id) + + # 次回リクエスト可能時刻の計算 + next_request_time = None + if not request_allowed: + next_request_time = self._get_next_reset_time() + + return { + "request_limit": { + "allowed": request_allowed, + "info": request_info + }, + "api_limit": { + "allowed": api_allowed, + "info": api_info + }, + "next_request_time": next_request_time, + "debug_mode": self.debug_mode + } + + except Exception as e: + logger.error(f"制限状況取得エラー: {e}") + return {"error": str(e)} + + async def reset_daily_counters(self) -> int: + """1日のカウンターをリセット(古いデータを削除)""" + try: + # 7日以上前のデータを削除 + cutoff_date = datetime.now() - timedelta(days=7) + cutoff_str = cutoff_date.strftime("%Y-%m-%d") + + all_users = await self.storage.get_all_users() + reset_count = 0 + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 古い1日のリクエストデータを削除 + daily_requests = user_data["rate_limits"]["daily_requests"] + dates_to_delete = [date for date in daily_requests.keys() if date < cutoff_str] + + for date in dates_to_delete: + del daily_requests[date] + reset_count += 1 + + # 古いAPI呼び出しデータを削除 + api_calls = user_data["rate_limits"]["api_calls"] + dates_to_delete = [date for date in api_calls.keys() if date < cutoff_str] + + for date in dates_to_delete: + del api_calls[date] + reset_count += 1 + + if reset_count > 0: + await self.storage.update_user_data(user_id, user_data) + + if reset_count > 0: + logger.info(f"{reset_count}件の古い制限データをリセットしました") + + return reset_count + + except Exception as e: + logger.error(f"カウンターリセットエラー: {e}") + return 0 + + def _get_next_reset_time(self) -> str: + """次のリセット時刻を取得(翌日の0時)""" + tomorrow = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + return tomorrow.isoformat() + + async def is_request_allowed(self, user_id: str) -> Tuple[bool, str]: + """リクエストが許可されているかチェック(統合チェック)""" + try: + # 1日のリクエスト制限チェック + request_allowed, request_info = await self.check_daily_request_limit(user_id) + + if not request_allowed: + remaining_time = self._calculate_remaining_time() + return False, f"1日のリクエスト制限に達しています。次回リクエスト可能時刻: {remaining_time}" + + # API呼び出し制限チェック + api_allowed, api_info = await self.check_api_call_limit(user_id) + + if not api_allowed: + remaining_time = self._calculate_remaining_time() + return False, f"API呼び出し制限に達しています。次回リクエスト可能時刻: {remaining_time}" + + return True, "リクエスト可能です" + + except Exception as e: + logger.error(f"リクエスト許可チェックエラー: {e}") + return False, f"制限チェック中にエラーが発生しました: {e}" + + def _calculate_remaining_time(self) -> str: + """次回リクエスト可能までの残り時間を計算""" + now = datetime.now() + tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1) + remaining = tomorrow - now + + hours = remaining.seconds // 3600 + minutes = (remaining.seconds % 3600) // 60 + + return f"{hours}時間{minutes}分後" + + async def get_rate_limit_stats(self) -> Dict[str, Any]: + """レート制限の統計情報を取得""" + try: + all_users = await self.storage.get_all_users() + today = datetime.now().strftime("%Y-%m-%d") + + total_requests_today = 0 + total_api_calls_today = 0 + active_users_today = 0 + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 今日のリクエスト数 + daily_requests = user_data["rate_limits"]["daily_requests"] + user_requests_today = daily_requests.get(today, 0) + total_requests_today += user_requests_today + + # 今日のAPI呼び出し数 + api_calls = user_data["rate_limits"]["api_calls"] + user_api_calls_today = api_calls.get(today, 0) + total_api_calls_today += user_api_calls_today + + # アクティブユーザー数 + if user_requests_today > 0 or user_api_calls_today > 0: + active_users_today += 1 + + return { + "total_users": len(all_users), + "active_users_today": active_users_today, + "total_requests_today": total_requests_today, + "total_api_calls_today": total_api_calls_today, + "max_daily_requests": self.max_daily_requests, + "max_api_calls_per_day": self.max_api_calls_per_day, + "debug_mode": self.debug_mode, + "date": today + } + + except Exception as e: + logger.error(f"統計情報取得エラー: {e}") + return {"error": str(e)} + + def is_debug_mode(self) -> bool: + """デバッグモードかどうかを確認""" + return self.debug_mode + + async def set_debug_mode(self, enabled: bool) -> None: + """デバッグモードの設定(動的変更)""" + self.debug_mode = enabled + + if enabled: + self.max_daily_requests = int(os.getenv("DEBUG_MAX_DAILY_REQUESTS", "10")) + self.max_api_calls_per_day = int(os.getenv("DEBUG_MAX_API_CALLS", "100")) + logger.info("デバッグモードを有効にしました") + else: + self.max_daily_requests = int(os.getenv("MAX_DAILY_REQUESTS", "1")) + self.max_api_calls_per_day = int(os.getenv("MAX_API_CALLS_PER_DAY", "10")) + logger.info("デバッグモードを無効にしました") + + async def force_reset_user_limits(self, user_id: str) -> None: + """特定ユーザーの制限を強制リセット(デバッグ用)""" + if not self.debug_mode: + raise RateLimitError("デバッグモードでのみ利用可能です") + + try: + user_data = await self.storage.get_user_data(user_id) + today = datetime.now().strftime("%Y-%m-%d") + + # 今日の制限をリセット + user_data["rate_limits"]["daily_requests"][today] = 0 + user_data["rate_limits"]["api_calls"][today] = 0 + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"ユーザー {user_id} の制限を強制リセットしました") + + except Exception as e: + logger.error(f"強制リセットエラー: {e}") + raise RateLimitError(f"制限のリセットに失敗しました: {e}") + + +# テスト用の関数 +async def test_rate_limit_manager(): + """RateLimitManagerのテスト""" + import tempfile + import uuid + from async_storage_manager import AsyncStorageManager + + # 一時ディレクトリでテスト + with tempfile.TemporaryDirectory() as temp_dir: + test_file = os.path.join(temp_dir, "test_letters.json") + storage = AsyncStorageManager(test_file) + rate_limiter = AsyncRateLimitManager(storage) + + print("=== RateLimitManagerテスト開始 ===") + + user_id = str(uuid.uuid4()) + + # 初回リクエストチェック + allowed, message = await rate_limiter.is_request_allowed(user_id) + print(f"✓ 初回リクエストチェック: {allowed} - {message}") + + # リクエスト記録 + await rate_limiter.record_request(user_id) + print("✓ リクエスト記録成功") + + # API呼び出し記録 + await rate_limiter.record_api_call(user_id, "groq") + print("✓ API呼び出し記録成功") + + # 制限状況確認 + status = await rate_limiter.get_user_limits_status(user_id) + print(f"✓ 制限状況確認: {status}") + + # 統計情報取得 + stats = await rate_limiter.get_rate_limit_stats() + print(f"✓ 統計情報取得: {stats}") + + # デバッグモードテスト + await rate_limiter.set_debug_mode(True) + print("✓ デバッグモード有効化成功") + + # 強制リセットテスト(デバッグモード時のみ) + await rate_limiter.force_reset_user_limits(user_id) + print("✓ 強制リセット成功") + + print("=== 全てのテストが完了しました! ===") + + +if __name__ == "__main__": + asyncio.run(test_rate_limit_manager()) \ No newline at end of file diff --git a/async_storage_manager.py b/async_storage_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..9f5f0d6e7a62737cf3ef7d7c33748febc0266966 --- /dev/null +++ b/async_storage_manager.py @@ -0,0 +1,417 @@ +""" +非同期ストレージ管理クラス +JSONファイルベースの永続ストレージを提供し、 +非同期ファイル操作とロック機能を実装します。 +""" + +import asyncio +import json +import os +import shutil +from datetime import datetime, timedelta +from typing import Dict, Any, Optional, List +from pathlib import Path +import logging + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class StorageError(Exception): + """ストレージ関連のエラー""" + pass + + +class AsyncStorageManager: + """非同期ストレージ管理クラス""" + + def __init__(self, file_path: str = "tmp/letters.json"): + self.file_path = Path(file_path) + self.backup_dir = self.file_path.parent / "backup" + self.lock = asyncio.Lock() + + # ディレクトリの作成 + self.file_path.parent.mkdir(parents=True, exist_ok=True) + self.backup_dir.mkdir(parents=True, exist_ok=True) + + # 初期データ構造 + self.default_data = { + "users": {}, + "system": { + "last_backup": None, + "batch_runs": {}, + "created_at": datetime.now().isoformat() + } + } + + async def load_data(self) -> Dict[str, Any]: + """データファイルを読み込み""" + async with self.lock: + try: + if not self.file_path.exists(): + logger.info("データファイルが存在しないため、初期データを作成します") + await self._save_data_unsafe(self.default_data) + return self.default_data.copy() + + # ファイルサイズチェック + if self.file_path.stat().st_size == 0: + logger.warning("データファイルが空のため、初期データを作成します") + await self._save_data_unsafe(self.default_data) + return self.default_data.copy() + + # JSONファイルの読み込み + with open(self.file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # データ構造の検証と修復 + data = self._validate_and_repair_data(data) + + logger.info(f"データファイルを正常に読み込みました: {self.file_path}") + return data + + except json.JSONDecodeError as e: + logger.error(f"JSONファイルの形式が不正です: {e}") + # バックアップからの復旧を試行 + return await self._restore_from_backup() + + except Exception as e: + logger.error(f"データ読み込みエラー: {e}") + raise StorageError(f"データの読み込みに失敗しました: {e}") + + async def save_data(self, data: Dict[str, Any]) -> None: + """データファイルに保存""" + async with self.lock: + await self._save_data_unsafe(data) + + async def _save_data_unsafe(self, data: Dict[str, Any]) -> None: + """ロックなしでデータを保存(内部使用)""" + try: + # データの検証 + validated_data = self._validate_and_repair_data(data) + + # 一時ファイルに書き込み + temp_path = self.file_path.with_suffix('.tmp') + + with open(temp_path, 'w', encoding='utf-8') as f: + json.dump(validated_data, f, ensure_ascii=False, indent=2) + + # アトミックな移動 + shutil.move(str(temp_path), str(self.file_path)) + + logger.info(f"データを正常に保存しました: {self.file_path}") + + except Exception as e: + logger.error(f"データ保存エラー: {e}") + # 一時ファイルのクリーンアップ + temp_path = self.file_path.with_suffix('.tmp') + if temp_path.exists(): + temp_path.unlink() + raise StorageError(f"データの保存に失敗しました: {e}") + + async def get_user_data(self, user_id: str) -> Dict[str, Any]: + """特定ユーザーのデータを取得""" + data = await self.load_data() + + if user_id not in data["users"]: + # 新規ユーザーの初期データを作成 + user_data = { + "profile": { + "created_at": datetime.now().isoformat(), + "last_request": None, + "total_letters": 0 + }, + "letters": {}, + "requests": {}, + "rate_limits": { + "daily_requests": {}, + "api_calls": {} + } + } + data["users"][user_id] = user_data + await self.save_data(data) + logger.info(f"新規ユーザーデータを作成しました: {user_id}") + + return data["users"][user_id] + + async def update_user_data(self, user_id: str, user_data: Dict[str, Any]) -> None: + """特定ユーザーのデータを更新""" + data = await self.load_data() + data["users"][user_id] = user_data + await self.save_data(data) + logger.info(f"ユーザーデータを更新しました: {user_id}") + + async def get_all_users(self) -> List[str]: + """全ユーザーIDのリストを取得""" + data = await self.load_data() + return list(data["users"].keys()) + + async def backup_data(self) -> str: + """データのバックアップを作成""" + try: + if not self.file_path.exists(): + logger.warning("バックアップ対象のファイルが存在しません") + return "" + + # バックアップファイル名(タイムスタンプ付き) + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_filename = f"letters_backup_{timestamp}.json" + backup_path = self.backup_dir / backup_filename + + # ファイルをコピー + shutil.copy2(str(self.file_path), str(backup_path)) + + # システム情報を更新 + data = await self.load_data() + data["system"]["last_backup"] = datetime.now().isoformat() + await self.save_data(data) + + logger.info(f"バックアップを作成しました: {backup_path}") + + # 古いバックアップファイルを削除(7日以上前) + await self._cleanup_old_backups() + + return str(backup_path) + + except Exception as e: + logger.error(f"バックアップ作成エラー: {e}") + raise StorageError(f"バックアップの作成に失敗しました: {e}") + + async def _restore_from_backup(self) -> Dict[str, Any]: + """最新のバックアップから復旧""" + try: + # バックアップファイルを検索 + backup_files = list(self.backup_dir.glob("letters_backup_*.json")) + + if not backup_files: + logger.warning("バックアップファイルが見つかりません。初期データを使用します") + await self._save_data_unsafe(self.default_data) + return self.default_data.copy() + + # 最新のバックアップファイルを選択 + latest_backup = max(backup_files, key=lambda p: p.stat().st_mtime) + + logger.info(f"バックアップから復旧します: {latest_backup}") + + with open(latest_backup, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 復旧したデータを保存 + await self._save_data_unsafe(data) + + return data + + except Exception as e: + logger.error(f"バックアップからの復旧に失敗: {e}") + logger.info("初期データを使用します") + await self._save_data_unsafe(self.default_data) + return self.default_data.copy() + + async def _cleanup_old_backups(self, days: int = 7) -> None: + """古いバックアップファイルを削除""" + try: + cutoff_date = datetime.now() - timedelta(days=days) + backup_files = list(self.backup_dir.glob("letters_backup_*.json")) + + deleted_count = 0 + for backup_file in backup_files: + file_time = datetime.fromtimestamp(backup_file.stat().st_mtime) + if file_time < cutoff_date: + backup_file.unlink() + deleted_count += 1 + + if deleted_count > 0: + logger.info(f"{deleted_count}個の古いバックアップファイルを削除しました") + + except Exception as e: + logger.error(f"バックアップファイルの削除エラー: {e}") + + def _validate_and_repair_data(self, data: Dict[str, Any]) -> Dict[str, Any]: + """データ構造の検証と修復""" + if not isinstance(data, dict): + logger.warning("データが辞書形式ではありません。初期データを使用します") + return self.default_data.copy() + + # 必要なキーの確認と修復 + if "users" not in data: + data["users"] = {} + + if "system" not in data: + data["system"] = self.default_data["system"].copy() + + # システム情報の修復 + system_defaults = { + "last_backup": None, + "batch_runs": {}, + "created_at": datetime.now().isoformat() + } + + for key, default_value in system_defaults.items(): + if key not in data["system"]: + data["system"][key] = default_value + + # ユーザーデータの修復 + for user_id, user_data in data["users"].items(): + if not isinstance(user_data, dict): + continue + + # 必要なキーの確認 + user_defaults = { + "profile": { + "created_at": datetime.now().isoformat(), + "last_request": None, + "total_letters": 0 + }, + "letters": {}, + "requests": {}, + "rate_limits": { + "daily_requests": {}, + "api_calls": {} + } + } + + for key, default_value in user_defaults.items(): + if key not in user_data: + user_data[key] = default_value + + return data + + async def get_system_info(self) -> Dict[str, Any]: + """システム情報を取得""" + data = await self.load_data() + return data["system"] + + async def update_system_info(self, system_info: Dict[str, Any]) -> None: + """システム情報を更新""" + data = await self.load_data() + data["system"].update(system_info) + await self.save_data(data) + + async def cleanup_old_data(self, days: int = 90) -> int: + """古いデータを削除""" + try: + cutoff_date = datetime.now() - timedelta(days=days) + cutoff_str = cutoff_date.strftime("%Y-%m-%d") + + data = await self.load_data() + deleted_count = 0 + + for user_id, user_data in data["users"].items(): + # 古い手紙を削除 + letters_to_delete = [] + for date_str in user_data["letters"]: + if date_str < cutoff_str: + letters_to_delete.append(date_str) + + for date_str in letters_to_delete: + del user_data["letters"][date_str] + deleted_count += 1 + + # 古いリクエストを削除 + requests_to_delete = [] + for date_str in user_data["requests"]: + if date_str < cutoff_str: + requests_to_delete.append(date_str) + + for date_str in requests_to_delete: + del user_data["requests"][date_str] + + # 古いレート制限データを削除 + for limit_type in ["daily_requests", "api_calls"]: + if limit_type in user_data["rate_limits"]: + dates_to_delete = [] + for date_str in user_data["rate_limits"][limit_type]: + if date_str < cutoff_str: + dates_to_delete.append(date_str) + + for date_str in dates_to_delete: + del user_data["rate_limits"][limit_type][date_str] + + if deleted_count > 0: + await self.save_data(data) + logger.info(f"{deleted_count}件の古いデータを削除しました") + + return deleted_count + + except Exception as e: + logger.error(f"古いデータの削除エラー: {e}") + return 0 + + async def get_storage_stats(self) -> Dict[str, Any]: + """ストレージの統計情報を取得""" + try: + data = await self.load_data() + + total_users = len(data["users"]) + total_letters = sum(len(user_data["letters"]) for user_data in data["users"].values()) + total_requests = sum(len(user_data["requests"]) for user_data in data["users"].values()) + + file_size = self.file_path.stat().st_size if self.file_path.exists() else 0 + backup_count = len(list(self.backup_dir.glob("letters_backup_*.json"))) + + return { + "total_users": total_users, + "total_letters": total_letters, + "total_requests": total_requests, + "file_size_bytes": file_size, + "backup_count": backup_count, + "last_backup": data["system"].get("last_backup"), + "created_at": data["system"].get("created_at") + } + + except Exception as e: + logger.error(f"統計情報の取得エラー: {e}") + return {} + + +# テスト用の関数 +async def test_storage_manager(): + """StorageManagerのテスト""" + import tempfile + import uuid + + # 一時ディレクトリでテスト + with tempfile.TemporaryDirectory() as temp_dir: + test_file = os.path.join(temp_dir, "test_letters.json") + storage = AsyncStorageManager(test_file) + + print("=== StorageManagerテスト開始 ===") + + # 初期データの読み込みテスト + data = await storage.load_data() + print("✓ 初期データの読み込み成功") + + # ユーザーデータの作成テスト + user_id = str(uuid.uuid4()) + user_data = await storage.get_user_data(user_id) + print("✓ ユーザーデータの作成成功") + + # ユーザーデータの更新テスト + user_data["profile"]["total_letters"] = 1 + user_data["letters"]["2024-01-20"] = { + "theme": "テストテーマ", + "content": "テスト手紙の内容", + "status": "completed", + "generated_at": datetime.now().isoformat() + } + await storage.update_user_data(user_id, user_data) + print("✓ ユーザーデータの更新成功") + + # データの再読み込みテスト + updated_data = await storage.get_user_data(user_id) + assert updated_data["profile"]["total_letters"] == 1 + print("✓ データの永続化確認成功") + + # バックアップテスト + backup_path = await storage.backup_data() + print(f"✓ バックアップ作成成功: {backup_path}") + + # 統計情報テスト + stats = await storage.get_storage_stats() + print(f"✓ 統計情報取得成功: {stats}") + + print("=== 全てのテストが完了しました! ===") + + +if __name__ == "__main__": + asyncio.run(test_storage_manager()) \ No newline at end of file diff --git a/background_processor.py b/background_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..53f5a37dad97764568e63e51f822638779d9f3b9 --- /dev/null +++ b/background_processor.py @@ -0,0 +1,616 @@ +""" +バックグラウンド処理統合クラス +Streamlitアプリと独立したバッチ処理の実行機能と +古いデータの自動削除機能を提供します。 +""" + +import asyncio +import threading +import time +import os +from datetime import datetime, timedelta +from typing import Dict, Any, Optional, Callable +import logging +import traceback +import signal +import sys + +from batch_scheduler import BatchScheduler +from async_storage_manager import AsyncStorageManager + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BackgroundProcessorError(Exception): + """バックグラウンドプロセッサー関連のエラー""" + pass + + +class BackgroundProcessor: + """Streamlitアプリと独立したバックグラウンド処理管理クラス""" + + def __init__(self, storage_manager: Optional[AsyncStorageManager] = None): + """ + バックグラウンドプロセッサーを初期化 + + Args: + storage_manager: ストレージマネージャー(指定しない場合は新規作成) + """ + # ストレージマネージャーの初期化 + if storage_manager is None: + storage_path = os.getenv("STORAGE_PATH", "/mnt/data/letters.json") + self.storage_manager = AsyncStorageManager(storage_path) + else: + self.storage_manager = storage_manager + + # バッチスケジューラーの初期化 + self.batch_scheduler = BatchScheduler(self.storage_manager) + + # 設定値 + self.target_hours = [2, 3, 4] # 2時、3時、4時 + self.check_interval = int(os.getenv("BATCH_CHECK_INTERVAL", "60")) # 1分間隔 + self.cleanup_hour = int(os.getenv("CLEANUP_HOUR", "1")) # 1時にクリーンアップ + self.cleanup_retention_days = int(os.getenv("CLEANUP_RETENTION_DAYS", "90")) + self.enable_background_processing = os.getenv("ENABLE_BACKGROUND_PROCESSING", "true").lower() == "true" + + # 実行状態管理 + self.is_running = False + self.background_thread = None + self.stop_event = threading.Event() + self.last_execution_times = {hour: None for hour in self.target_hours} + self.last_cleanup_date = None + + # コールバック関数 + self.on_batch_complete: Optional[Callable] = None + self.on_cleanup_complete: Optional[Callable] = None + self.on_error: Optional[Callable] = None + + logger.info(f"BackgroundProcessor初期化完了 - 対象時刻: {self.target_hours}, チェック間隔: {self.check_interval}秒") + + def start_background_processing(self) -> bool: + """ + バックグラウンド処理を開始 + + Returns: + bool: 開始成功フラグ + """ + if not self.enable_background_processing: + logger.info("バックグラウンド処理は無効化されています") + return False + + if self.is_running: + logger.warning("バックグラウンド処理は既に実行中です") + return False + + try: + self.is_running = True + self.stop_event.clear() + + # バックグラウンドスレッドを開始 + self.background_thread = threading.Thread( + target=self._background_loop, + name="BackgroundProcessor", + daemon=True + ) + self.background_thread.start() + + # シグナルハンドラーを設定 + self._setup_signal_handlers() + + logger.info("バックグラウンド処理を開始しました") + return True + + except Exception as e: + self.is_running = False + logger.error(f"バックグラウンド処理の開始に失敗: {str(e)}") + return False + + def stop_background_processing(self) -> bool: + """ + バックグラウンド処理を停止 + + Returns: + bool: 停止成功フラグ + """ + if not self.is_running: + logger.info("バックグラウンド処理は実行されていません") + return True + + try: + logger.info("バックグラウンド処理の停止を開始します...") + + # 停止フラグを設定 + self.stop_event.set() + self.is_running = False + + # スレッドの終了を待機 + if self.background_thread and self.background_thread.is_alive(): + self.background_thread.join(timeout=30) # 30秒でタイムアウト + + if self.background_thread.is_alive(): + logger.warning("バックグラウンドスレッドの停止がタイムアウトしました") + return False + + logger.info("バックグラウンド処理を停止しました") + return True + + except Exception as e: + logger.error(f"バックグラウンド処理の停止に失敗: {str(e)}") + return False + + def start_background_processing(self) -> bool: + if not self.enable_background_processing: + logger.info("バックグラウンド処理は無効化されています") + return False + if self.is_running: + logger.warning("バックグラウンド処理は既に実行中です") + return False + + try: + self.is_running = True + self.stop_event.clear() + + # 変更点 1: スレッドのターゲットを新しいラッパー関数に変更 + self.background_thread = threading.Thread( + target=self._thread_entry_point, + name="BackgroundProcessor", + daemon=True + ) + self.background_thread.start() + + self._setup_signal_handlers() + logger.info("バックグラウンド処理を開始しました") + return True + except Exception as e: + self.is_running = False + logger.error(f"バックグラウンド処理の開始に失敗: {e}") + return False + + def stop_background_processing(self) -> bool: + if not self.is_running: + logger.info("バックグラウンド処理は実行されていません") + return True + + try: + logger.info("バックグラウンド処理の停止を開始します...") + self.stop_event.set() + if self.background_thread and self.background_thread.is_alive(): + self.background_thread.join(timeout=10) + if self.background_thread.is_alive(): + logger.warning("バックグラウンドスレッドの停止がタイムアウトしました") + return False + + self.is_running = False + logger.info("バックグラウンド処理を停止しました") + return True + except Exception as e: + logger.error(f"バックグラウンド処理の停止に失敗: {e}") + return False + + # 変更点 2: スレッドのエントリーポイントとなる同期ラッパー関数を追加 + def _thread_entry_point(self) -> None: + """バックグラウンドスレッド内でイベントループを実行するためのラッパー""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + logger.info("バックグラウンドスレッドのイベントループを開始します。") + loop.run_until_complete(self._background_loop()) + except Exception as e: + logger.error(f"バックグラウンドイベントループで致命的なエラー: {e}\n{traceback.format_exc()}") + finally: + logger.info("バックグラウンドスレッドのイベントループを終了します。") + loop.close() + + # 変更点 3: メインループを async def に変更 + async def _background_loop(self) -> None: + """バックグラウンド処理の非同期メインループ""" + logger.info("非同期バックグラウンド処理ループを開始します") + while not self.stop_event.is_set(): + try: + current_time = datetime.now() + current_hour = current_time.hour + current_date = current_time.strftime("%Y-%m-%d") + + if current_hour in self.target_hours: + await self._check_and_run_batch(current_hour, current_date) + + if current_hour == self.cleanup_hour: + await self._check_and_run_cleanup(current_date) + + # 変更点 4: 待機処理を asyncio.sleep に変更 + # stop_eventをチェックしながら1秒ずつ待機することで、素早い停止を可能にする + for _ in range(self.check_interval): + if self.stop_event.is_set(): + break + await asyncio.sleep(1) + + except Exception as e: + error_msg = f"バックグラウンド処理ループでエラーが発生: {e}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + if self.on_error: + try: + self.on_error(error_msg) + except Exception as callback_error: + logger.error(f"エラーコールバック実行エラー: {callback_error}") + await asyncio.sleep(min(self.check_interval * 2, 300)) + + logger.info("非同期バックグラウンド処理ループを終了しました") + + async def _check_and_run_batch(self, hour: int, date: str) -> None: + """ + バッチ処理の実行チェックと実行 + + Args: + hour: 現在の時刻 + date: 現在の日付 + """ + try: + # 既に今日実行済みかチェック + last_execution = self.last_execution_times.get(hour) + if last_execution and last_execution == date: + return # 既に実行済み + + logger.info(f"{hour}時のバッチ処理を実行します") + + # バッチ処理を実行 + result = await self.batch_scheduler.run_hourly_batch(hour) + + # 実行時刻を記録 + self.last_execution_times[hour] = date + + # 完了コールバックを呼び出し + if self.on_batch_complete: + try: + self.on_batch_complete(hour, result) + except Exception as callback_error: + logger.error(f"バッチ完了コールバック実行エラー: {str(callback_error)}") + + if result.get("success", False): + logger.info(f"{hour}時のバッチ処理が完了しました - 処理数: {result.get('processed_count', 0)}") + else: + logger.error(f"{hour}時のバッチ処理が失敗しました: {result.get('error', '不明なエラー')}") + + except Exception as e: + error_msg = f"{hour}時のバッチ処理チェック中にエラーが発生: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + + if self.on_error: + try: + self.on_error(error_msg) + except Exception as callback_error: + logger.error(f"エラーコールバック実行エラー: {str(callback_error)}") + + async def _check_and_run_cleanup(self, date: str) -> None: + """ + クリーンアップ処理の実行チェックと実行 + + Args: + date: 現在の日付 + """ + try: + # 既に今日実行済みかチェック + if self.last_cleanup_date == date: + return # 既に実行済み + + logger.info("古いデータのクリーンアップを実行します") + + # クリーンアップ処理を実行 + result = await self.batch_scheduler.cleanup_old_data(self.cleanup_retention_days) + + # 実行日を記録 + self.last_cleanup_date = date + + # 完了コールバックを呼び出し + if self.on_cleanup_complete: + try: + self.on_cleanup_complete(result) + except Exception as callback_error: + logger.error(f"クリーンアップ完了コールバック実行エラー: {str(callback_error)}") + + if result.get("success", False): + logger.info(f"クリーンアップが完了しました - 削除数: {result.get('deleted_letters', 0)}") + else: + logger.error(f"クリーンアップが失敗しました: {result.get('error', '不明なエラー')}") + + except Exception as e: + error_msg = f"クリーンアップ処理チェック中にエラーが発生: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + + if self.on_error: + try: + self.on_error(error_msg) + except Exception as callback_error: + logger.error(f"エラーコールバック実行エラー: {str(callback_error)}") + + def _setup_signal_handlers(self) -> None: + """シグナルハンドラーを設定""" + def signal_handler(signum, frame): + logger.info(f"シグナル {signum} を受信しました。バックグラウンド処理を停止します...") + self.stop_background_processing() + sys.exit(0) + + # SIGTERM と SIGINT のハンドラーを設定 + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + def get_status(self) -> Dict[str, Any]: + """ + バックグラウンド処理の状態を取得 + + Returns: + Dict: 状態情報 + """ + return { + "is_running": self.is_running, + "enable_background_processing": self.enable_background_processing, + "target_hours": self.target_hours, + "check_interval": self.check_interval, + "cleanup_hour": self.cleanup_hour, + "cleanup_retention_days": self.cleanup_retention_days, + "last_execution_times": self.last_execution_times.copy(), + "last_cleanup_date": self.last_cleanup_date, + "thread_alive": self.background_thread.is_alive() if self.background_thread else False, + "current_time": datetime.now().isoformat() + } + + async def force_run_batch(self, hour: int) -> Dict[str, Any]: + """ + 指定時刻のバッチ処理を強制実行 + + Args: + hour: 実行対象の時刻 + + Returns: + Dict: 実行結果 + """ + try: + if hour not in self.target_hours: + return { + "success": False, + "error": f"無効な時刻が指定されました: {hour} (有効: {self.target_hours})" + } + + logger.info(f"{hour}時のバッチ処理を強制実行します") + + result = await self.batch_scheduler.run_hourly_batch(hour) + + # 実行時刻を記録 + current_date = datetime.now().strftime("%Y-%m-%d") + self.last_execution_times[hour] = current_date + + return result + + except Exception as e: + error_msg = f"バッチ処理の強制実行中にエラーが発生: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + return { + "success": False, + "error": error_msg + } + + async def force_run_cleanup(self) -> Dict[str, Any]: + """ + クリーンアップ処理を強制実行 + + Returns: + Dict: 実行結果 + """ + try: + logger.info("クリーンアップ処理を強制実行します") + + result = await self.batch_scheduler.cleanup_old_data(self.cleanup_retention_days) + + # 実行日を記録 + current_date = datetime.now().strftime("%Y-%m-%d") + self.last_cleanup_date = current_date + + return result + + except Exception as e: + error_msg = f"クリーンアップ処理の強制実行中にエラーが発生: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + return { + "success": False, + "error": error_msg + } + + def set_callbacks(self, + on_batch_complete: Optional[Callable] = None, + on_cleanup_complete: Optional[Callable] = None, + on_error: Optional[Callable] = None) -> None: + """ + コールバック関数を設定 + + Args: + on_batch_complete: バッチ処理完了時のコールバック + on_cleanup_complete: クリーンアップ完了時のコールバック + on_error: エラー発生時のコールバック + """ + self.on_batch_complete = on_batch_complete + self.on_cleanup_complete = on_cleanup_complete + self.on_error = on_error + + logger.info("コールバック関数を設定しました") + + async def get_processing_statistics(self, days: int = 7) -> Dict[str, Any]: + """ + 処理統計情報を取得 + + Args: + days: 統計対象日数 + + Returns: + Dict: 統計情報 + """ + try: + # バッチスケジューラーから統計を取得 + batch_stats = await self.batch_scheduler.get_batch_statistics(days) + + # ストレージ統計を取得 + storage_stats = await self.storage_manager.get_storage_stats() + + # バックグラウンド処理の状態を追加 + status = self.get_status() + + return { + "background_processor": status, + "batch_statistics": batch_stats, + "storage_statistics": storage_stats, + "generated_at": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"統計情報取得エラー: {str(e)}") + return {"error": str(e)} + + def __enter__(self): + """コンテキストマネージャーのエントリー""" + self.start_background_processing() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """コンテキストマネージャーの終了""" + self.stop_background_processing() + + +class StreamlitBackgroundIntegration: + """Streamlitアプリとバックグラウンド処理の統合クラス""" + + def __init__(self): + self.background_processor = None + self.is_initialized = False + + def initialize(self, storage_manager: Optional[AsyncStorageManager] = None) -> bool: + """ + バックグラウンド処理を初期化 + + Args: + storage_manager: ストレージマネージャー + + Returns: + bool: 初期化成功フラグ + """ + try: + if self.is_initialized: + return True + + self.background_processor = BackgroundProcessor(storage_manager) + + # コールバック関数を設定 + self.background_processor.set_callbacks( + on_batch_complete=self._on_batch_complete, + on_cleanup_complete=self._on_cleanup_complete, + on_error=self._on_error + ) + + # バックグラウンド処理を開始 + success = self.background_processor.start_background_processing() + + if success: + self.is_initialized = True + logger.info("Streamlitバックグラウンド統合を初期化しました") + + return success + + except Exception as e: + logger.error(f"バックグラウンド統合の初期化に失敗: {str(e)}") + return False + + def shutdown(self) -> bool: + """ + バックグラウンド処理を終了 + + Returns: + bool: 終了成功フラグ + """ + try: + if not self.is_initialized or not self.background_processor: + return True + + success = self.background_processor.stop_background_processing() + + if success: + self.is_initialized = False + logger.info("Streamlitバックグラウンド統合を終了しました") + + return success + + except Exception as e: + logger.error(f"バックグラウンド統合の終了に失敗: {str(e)}") + return False + + def get_status(self) -> Dict[str, Any]: + """統合状態を取得""" + if not self.is_initialized or not self.background_processor: + return {"initialized": False, "running": False} + + status = self.background_processor.get_status() + status["initialized"] = self.is_initialized + + return status + + async def get_statistics(self, days: int = 7) -> Dict[str, Any]: + """統計情報を取得""" + if not self.is_initialized or not self.background_processor: + return {"error": "バックグラウンド処理が初期化されていません"} + + return await self.background_processor.get_processing_statistics(days) + + def _on_batch_complete(self, hour: int, result: Dict[str, Any]) -> None: + """バッチ処理完了時のコールバック""" + logger.info(f"バッチ処理完了通知 - {hour}時: {result.get('success', False)}") + # Streamlitの状態更新やキャッシュクリアなどを実装可能 + + def _on_cleanup_complete(self, result: Dict[str, Any]) -> None: + """クリーンアップ完了時のコールバック""" + logger.info(f"クリーンアップ完了通知: {result.get('success', False)}") + # Streamlitの状態更新やキャッシュクリアなどを実装可能 + + def _on_error(self, error_message: str) -> None: + """エラー発生時のコールバック""" + logger.error(f"バックグラウンド処理エラー通知: {error_message}") + # Streamlitのエラー表示やアラート機能を実装可能 + + +# グローバルインスタンス(Streamlitアプリで使用) +streamlit_background = StreamlitBackgroundIntegration() + + +# テスト用の関数 +async def test_background_processor(): + """BackgroundProcessorのテスト""" + import tempfile + + # 一時ディレクトリでテスト + with tempfile.TemporaryDirectory() as temp_dir: + test_file = os.path.join(temp_dir, "test_letters.json") + storage = AsyncStorageManager(test_file) + + print("=== BackgroundProcessorテスト開始 ===") + + # バックグラウンドプロセッサーのテスト + processor = BackgroundProcessor(storage) + + # 状態確認テスト + status = processor.get_status() + print(f"✓ 状態確認テスト: {status['is_running']}") + + # 強制バッチ実行テスト + batch_result = await processor.force_run_batch(2) + print(f"✓ 強制バッチ実行テスト: {batch_result['success']}") + + # 強制クリーンアップテスト + cleanup_result = await processor.force_run_cleanup() + print(f"✓ 強制クリーンアップテスト: {cleanup_result['success']}") + + # 統計情報テスト + stats = await processor.get_processing_statistics() + print(f"✓ 統計情報取得テスト: {'error' not in stats}") + + print("=== 全てのテストが完了しました! ===") + + +if __name__ == "__main__": + asyncio.run(test_background_processor()) \ No newline at end of file diff --git a/batch_scheduler.py b/batch_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..bde918e715430f527b1e72314e982a6ec881fb05 --- /dev/null +++ b/batch_scheduler.py @@ -0,0 +1,587 @@ +""" +バッチスケジューラークラス +2時、3時、4時の時刻別バッチ処理機能を実装し、 +指定時刻の未処理リクエストを処理する機能を提供します。 +""" + +import asyncio +import os +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional +import logging +import traceback + +from letter_request_manager import RequestManager +from letter_generator import LetterGenerator +from async_storage_manager import AsyncStorageManager +from async_rate_limiter import AsyncRateLimitManager +from letter_user_manager import UserManager + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BatchSchedulerError(Exception): + """バッチスケジューラー関連のエラー""" + pass + + +class BatchScheduler: + """非同期バッチ処理スケジューラー""" + + def __init__(self, storage_manager: Optional[AsyncStorageManager] = None): + """ + バッチスケジューラーを初期化 + + Args: + storage_manager: ストレージマネージャー(指定しない場合は新規作成) + """ + # ストレージマネージャーの初期化 + if storage_manager is None: + storage_path = os.getenv("STORAGE_PATH", "/mnt/data/letters.json") + self.storage_manager = AsyncStorageManager(storage_path) + else: + self.storage_manager = storage_manager + + # 依存コンポーネントの初期化 + self.rate_limiter = AsyncRateLimitManager(self.storage_manager) + self.request_manager = RequestManager(self.storage_manager, self.rate_limiter) + self.letter_generator = LetterGenerator() + self.user_manager = UserManager(self.storage_manager) + + # 設定値 + self.available_hours = [2, 3, 4] # 2時、3時、4時 + self.max_concurrent_generations = int(os.getenv("MAX_CONCURRENT_GENERATIONS", "3")) + self.generation_timeout = int(os.getenv("GENERATION_TIMEOUT", "300")) # 5分 + self.retry_failed_requests = os.getenv("RETRY_FAILED_REQUESTS", "true").lower() == "true" + + logger.info(f"BatchScheduler初期化完了 - 対象時刻: {self.available_hours}") + + async def run_hourly_batch(self, hour: int) -> Dict[str, Any]: + """ + 指定時刻のバッチ処理を実行 + + Args: + hour: 実行時刻(2, 3, 4のいずれか) + + Returns: + Dict: バッチ処理の結果 + """ + start_time = datetime.now() + batch_id = f"{start_time.strftime('%Y%m%d_%H%M%S')}_{hour}" + + logger.info(f"=== {hour}時のバッチ処理開始 (ID: {batch_id}) ===") + + # 時刻の検証 + if hour not in self.available_hours: + error_msg = f"無効な時刻が指定されました: {hour}時 (有効: {self.available_hours})" + logger.error(error_msg) + return { + "success": False, + "error": error_msg, + "batch_id": batch_id, + "hour": hour, + "start_time": start_time.isoformat(), + "end_time": datetime.now().isoformat() + } + + try: + # バッチ実行の記録開始 + await self._record_batch_start(batch_id, hour, start_time) + + # 指定時刻の未処理リクエストを処理 + result = await self.process_pending_requests_for_hour(hour) + + # バッチ実行の記録完了 + end_time = datetime.now() + await self._record_batch_completion(batch_id, hour, start_time, end_time, result) + + execution_time = (end_time - start_time).total_seconds() + + logger.info(f"=== {hour}時のバッチ処理完了 (所要時間: {execution_time:.2f}秒) ===") + + return { + "success": True, + "batch_id": batch_id, + "hour": hour, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "execution_time": execution_time, + "processed_count": result.get("processed_count", 0), + "success_count": result.get("success_count", 0), + "failed_count": result.get("failed_count", 0), + "errors": result.get("errors", []) + } + + except Exception as e: + end_time = datetime.now() + execution_time = (end_time - start_time).total_seconds() + error_msg = f"バッチ処理中にエラーが発生しました: {str(e)}" + + logger.error(f"{error_msg}\n{traceback.format_exc()}") + + # エラーの記録 + await self._record_batch_error(batch_id, hour, start_time, end_time, error_msg) + + return { + "success": False, + "error": error_msg, + "batch_id": batch_id, + "hour": hour, + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "execution_time": execution_time + } + + async def process_pending_requests_for_hour(self, hour: int) -> Dict[str, Any]: + """ + 指定時刻の未処理リクエストを処理 + + Args: + hour: 処理対象の時刻 + + Returns: + Dict: 処理結果の詳細 + """ + try: + # 未処理リクエストを取得 + pending_requests = await self.request_manager.get_pending_requests_by_hour(hour) + + if not pending_requests: + logger.info(f"{hour}時の未処理リクエストはありません") + return { + "processed_count": 0, + "success_count": 0, + "failed_count": 0, + "errors": [] + } + + logger.info(f"{hour}時の未処理リクエスト数: {len(pending_requests)}") + + # 並行処理用のセマフォ + semaphore = asyncio.Semaphore(self.max_concurrent_generations) + + # 各リクエストを並行処理 + tasks = [] + for request in pending_requests: + task = self._process_single_request(semaphore, request) + tasks.append(task) + + # 全てのタスクを実行 + results = await asyncio.gather(*tasks, return_exceptions=True) + + # 結果を集計 + success_count = 0 + failed_count = 0 + errors = [] + + for i, result in enumerate(results): + if isinstance(result, Exception): + failed_count += 1 + error_msg = f"リクエスト処理例外: {str(result)}" + errors.append({ + "request_index": i, + "user_id": pending_requests[i].get("user_id", "unknown"), + "error": error_msg + }) + logger.error(error_msg) + elif result.get("success", False): + success_count += 1 + else: + failed_count += 1 + errors.append({ + "request_index": i, + "user_id": pending_requests[i].get("user_id", "unknown"), + "error": result.get("error", "不明なエラー") + }) + + logger.info(f"処理完了 - 成功: {success_count}, 失敗: {failed_count}") + + return { + "processed_count": len(pending_requests), + "success_count": success_count, + "failed_count": failed_count, + "errors": errors + } + + except Exception as e: + error_msg = f"未処理リクエスト処理中にエラーが発生: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + return { + "processed_count": 0, + "success_count": 0, + "failed_count": 0, + "errors": [{"error": error_msg}] + } + + async def _process_single_request(self, semaphore: asyncio.Semaphore, request: Dict[str, Any]) -> Dict[str, Any]: + """ + 単一のリクエストを処理 + + Args: + semaphore: 並行処理制御用のセマフォ + request: 処理対象のリクエスト + + Returns: + Dict: 処理結果 + """ + async with semaphore: + user_id = request["user_id"] + theme = request["theme"] + date = request["date"] + + try: + logger.info(f"手紙生成開始 - ユーザー: {user_id}, テーマ: {theme[:50]}...") + + # タイムアウト付きで手紙生成を実行 + user_history = await self.user_manager.get_user_profile(user_id) + + generation_task = self.letter_generator.generate_letter(user_id, theme, user_history) + letter_result = await asyncio.wait_for(generation_task, timeout=self.generation_timeout) + + # 生成された手紙をストレージに保存 + await self._save_generated_letter(user_id, date, theme, letter_result) + + # リクエストを完了としてマーク + await self.request_manager.mark_request_processed(user_id, date, "completed") + + # ユーザー履歴を更新 + await self.user_manager.update_user_history(user_id, { + "date": date, + "theme": theme, + "status": "completed", + "generated_at": datetime.now().isoformat() + }) + + logger.info(f"手紙生成完了 - ユーザー: {user_id}") + + return { + "success": True, + "user_id": user_id, + "theme": theme, + "generation_time": letter_result["metadata"].get("generation_time", 0) + } + + except asyncio.TimeoutError: + error_msg = f"手紙生成がタイムアウトしました({self.generation_timeout}秒)" + logger.error(f"{error_msg} - ユーザー: {user_id}") + + await self.request_manager.mark_request_failed(user_id, date, error_msg) + + return { + "success": False, + "user_id": user_id, + "error": error_msg + } + + except Exception as e: + error_msg = f"手紙生成中にエラーが発生: {str(e)}" + logger.error(f"{error_msg} - ユーザー: {user_id}\n{traceback.format_exc()}") + + await self.request_manager.mark_request_failed(user_id, date, error_msg) + + return { + "success": False, + "user_id": user_id, + "error": error_msg + } + + async def _save_generated_letter(self, user_id: str, date: str, theme: str, letter_result: Dict[str, Any]) -> None: + """ + 生成された手紙をストレージに保存 + + Args: + user_id: ユーザーID + date: 日付 + theme: テーマ + letter_result: 生成結果 + """ + try: + user_data = await self.storage_manager.get_user_data(user_id) + + # 手紙データを作成 + letter_data = { + "theme": theme, + "content": letter_result["content"], + "status": "completed", + "generated_at": datetime.now().isoformat(), + "metadata": letter_result["metadata"] + } + + # ユーザーデータに追加 + user_data["letters"][date] = letter_data + user_data["profile"]["total_letters"] = user_data["profile"].get("total_letters", 0) + 1 + user_data["profile"]["last_request"] = date + + # ストレージに保存 + await self.storage_manager.update_user_data(user_id, user_data) + + logger.info(f"手紙をストレージに保存しました - ユーザー: {user_id}, 日付: {date}") + + except Exception as e: + logger.error(f"手紙保存エラー - ユーザー: {user_id}, 日付: {date}: {str(e)}") + raise + + async def _record_batch_start(self, batch_id: str, hour: int, start_time: datetime) -> None: + """バッチ実行開始を記録""" + try: + system_info = await self.storage_manager.get_system_info() + + if "batch_runs" not in system_info: + system_info["batch_runs"] = {} + + system_info["batch_runs"][batch_id] = { + "hour": hour, + "start_time": start_time.isoformat(), + "status": "running" + } + + await self.storage_manager.update_system_info(system_info) + + except Exception as e: + logger.error(f"バッチ開始記録エラー: {str(e)}") + + async def _record_batch_completion(self, batch_id: str, hour: int, start_time: datetime, + end_time: datetime, result: Dict[str, Any]) -> None: + """バッチ実行完了を記録""" + try: + system_info = await self.storage_manager.get_system_info() + + if batch_id in system_info.get("batch_runs", {}): + system_info["batch_runs"][batch_id].update({ + "end_time": end_time.isoformat(), + "status": "completed", + "execution_time": (end_time - start_time).total_seconds(), + "processed_count": result.get("processed_count", 0), + "success_count": result.get("success_count", 0), + "failed_count": result.get("failed_count", 0), + "error_count": len(result.get("errors", [])) + }) + + await self.storage_manager.update_system_info(system_info) + + except Exception as e: + logger.error(f"バッチ完了記録エラー: {str(e)}") + + async def _record_batch_error(self, batch_id: str, hour: int, start_time: datetime, + end_time: datetime, error_msg: str) -> None: + """バッチ実行エラーを記録""" + try: + system_info = await self.storage_manager.get_system_info() + + if batch_id in system_info.get("batch_runs", {}): + system_info["batch_runs"][batch_id].update({ + "end_time": end_time.isoformat(), + "status": "failed", + "execution_time": (end_time - start_time).total_seconds(), + "error": error_msg + }) + + await self.storage_manager.update_system_info(system_info) + + except Exception as e: + logger.error(f"バッチエラー記録エラー: {str(e)}") + + def schedule_all_hours(self) -> None: + """ + 全ての対象時刻でのスケジュール設定 + 注意: この関数は実際のスケジューリングライブラリと組み合わせて使用する + """ + logger.info("バッチスケジュールを設定します") + + for hour in self.available_hours: + logger.info(f" - {hour}:00 に手紙生成バッチを設定") + + # 実際のスケジューリングは外部ライブラリ(APScheduler等)で実装 + # ここでは設定情報のログ出力のみ + logger.info("スケジュール設定完了(実際の実行は外部スケジューラーが必要)") + + async def cleanup_old_data(self, days: int = 90) -> Dict[str, Any]: + """ + 古いデータの自動削除 + + Args: + days: 保持日数 + + Returns: + Dict: 削除結果 + """ + try: + logger.info(f"{days}日以前の古いデータを削除します") + + # ストレージマネージャーの削除機能を使用 + deleted_count = await self.storage_manager.cleanup_old_data(days) + + # リクエストマネージャーの削除機能も使用 + deleted_requests = await self.request_manager.cleanup_old_requests(days) + + # バックアップの作成 + backup_path = await self.storage_manager.backup_data() + + result = { + "success": True, + "deleted_letters": deleted_count, + "deleted_requests": deleted_requests, + "backup_created": backup_path, + "cleanup_date": datetime.now().isoformat() + } + + logger.info(f"古いデータ削除完了: {result}") + return result + + except Exception as e: + error_msg = f"古いデータ削除中にエラーが発生: {str(e)}" + logger.error(f"{error_msg}\n{traceback.format_exc()}") + + return { + "success": False, + "error": error_msg, + "cleanup_date": datetime.now().isoformat() + } + + async def get_batch_statistics(self, days: int = 7) -> Dict[str, Any]: + """ + バッチ処理の統計情報を取得 + + Args: + days: 統計対象日数 + + Returns: + Dict: 統計情報 + """ + try: + system_info = await self.storage_manager.get_system_info() + batch_runs = system_info.get("batch_runs", {}) + + # 指定日数以内のバッチ実行を抽出 + cutoff_date = datetime.now() - timedelta(days=days) + recent_batches = [] + + for batch_id, batch_info in batch_runs.items(): + try: + start_time = datetime.fromisoformat(batch_info["start_time"]) + if start_time >= cutoff_date: + recent_batches.append(batch_info) + except (ValueError, KeyError): + continue + + # 統計を計算 + total_runs = len(recent_batches) + successful_runs = len([b for b in recent_batches if b.get("status") == "completed"]) + failed_runs = len([b for b in recent_batches if b.get("status") == "failed"]) + + total_processed = sum(b.get("processed_count", 0) for b in recent_batches) + total_success = sum(b.get("success_count", 0) for b in recent_batches) + total_failed = sum(b.get("failed_count", 0) for b in recent_batches) + + avg_execution_time = 0 + if recent_batches: + execution_times = [b.get("execution_time", 0) for b in recent_batches if b.get("execution_time")] + if execution_times: + avg_execution_time = sum(execution_times) / len(execution_times) + + # 時刻別統計 + hourly_stats = {hour: {"runs": 0, "processed": 0, "success": 0} for hour in self.available_hours} + + for batch in recent_batches: + hour = batch.get("hour") + if hour in hourly_stats: + hourly_stats[hour]["runs"] += 1 + hourly_stats[hour]["processed"] += batch.get("processed_count", 0) + hourly_stats[hour]["success"] += batch.get("success_count", 0) + + return { + "period_days": days, + "total_runs": total_runs, + "successful_runs": successful_runs, + "failed_runs": failed_runs, + "success_rate": (successful_runs / total_runs * 100) if total_runs > 0 else 0, + "total_processed": total_processed, + "total_success": total_success, + "total_failed": total_failed, + "processing_success_rate": (total_success / total_processed * 100) if total_processed > 0 else 0, + "avg_execution_time": avg_execution_time, + "hourly_stats": hourly_stats, + "generated_at": datetime.now().isoformat() + } + + except Exception as e: + logger.error(f"統計情報取得エラー: {str(e)}") + return {"error": str(e)} + + async def test_batch_processing(self, test_hour: int = 2) -> Dict[str, Any]: + """ + バッチ処理のテスト実行 + + Args: + test_hour: テスト対象の時刻 + + Returns: + Dict: テスト結果 + """ + try: + logger.info(f"バッチ処理テストを開始します({test_hour}時)") + + # API接続テスト + api_status = await self.letter_generator.check_api_connections() + if not api_status.get("overall", False): + return { + "success": False, + "error": "API接続テストに失敗しました", + "api_status": api_status + } + + # テスト用のバッチ処理を実行 + result = await self.run_hourly_batch(test_hour) + + return { + "success": True, + "message": "バッチ処理テスト完了", + "api_status": api_status, + "batch_result": result + } + + except Exception as e: + return { + "success": False, + "error": f"バッチ処理テスト中にエラーが発生: {str(e)}" + } + + +# テスト用の関数 +async def test_batch_scheduler(): + """BatchSchedulerのテスト""" + import tempfile + import uuid + + # 一時ディレクトリでテスト + with tempfile.TemporaryDirectory() as temp_dir: + test_file = os.path.join(temp_dir, "test_letters.json") + storage = AsyncStorageManager(test_file) + scheduler = BatchScheduler(storage) + + print("=== BatchSchedulerテスト開始 ===") + + # テスト用のリクエストを作成 + user_id = str(uuid.uuid4()) + success, message = await scheduler.request_manager.submit_request(user_id, "テストテーマ", 2) + print(f"✓ テストリクエスト作成: {success} - {message}") + + # バッチ処理テスト + result = await scheduler.run_hourly_batch(2) + print(f"✓ バッチ処理テスト: {result['success']}") + + # 統計情報テスト + stats = await scheduler.get_batch_statistics() + print(f"✓ 統計情報取得テスト: {stats}") + + # 古いデータ削除テスト + cleanup_result = await scheduler.cleanup_old_data(0) # 全て削除 + print(f"✓ データ削除テスト: {cleanup_result['success']}") + + print("=== 全てのテストが完了しました! ===") + + +if __name__ == "__main__": + asyncio.run(test_batch_scheduler()) \ No newline at end of file diff --git a/bijyutukann-yoru.jpg b/bijyutukann-yoru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a636e7a214b59459cc179b486f1d08c133813aed --- /dev/null +++ b/bijyutukann-yoru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7afd304f7dea03c30105f56cb31ef990c298a6a4324d72c3b46ac99fb48514fb +size 253383 diff --git a/components_chat_interface.py b/components_chat_interface.py new file mode 100644 index 0000000000000000000000000000000000000000..add3c865a64152c71ab2d2ef394e39e867ad6653 --- /dev/null +++ b/components_chat_interface.py @@ -0,0 +1,897 @@ +""" +チャットインターフェースコンポーネント +Streamlitのチャット機能を使用したメッセージ表示と入力処理 +マスクアイコンとフリップアニメーション機能を含む +""" +import streamlit as st +import logging +import re +import uuid +from typing import List, Dict, Optional, Tuple +from datetime import datetime + +logger = logging.getLogger(__name__) + +class ChatInterface: + """チャットインターフェースを管理するクラス""" + + def __init__(self, max_input_length: int = 1000): + """ + Args: + max_input_length: 入力メッセージの最大長 + """ + self.max_input_length = max_input_length + + def render_chat_history(self, messages: List[Dict[str, str]], + memory_summary: str = "") -> None: + """ + チャット履歴を表示する(マスク機能付き、最適化版) + + Args: + messages: チャットメッセージのリスト + memory_summary: メモリサマリー(重要単語から生成) + """ + logger.info(f"🎯 render_chat_history 開始: {len(messages) if messages else 0}件のメッセージ") + try: + # 初期メッセージの存在確認と復元 + if messages: + initial_messages = [msg for msg in messages if msg.get('is_initial', False)] + if not initial_messages: + logger.warning("初期メッセージが見つかりません - 復元を試行") + # 初期メッセージが存在しない場合は先頭に追加 + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + messages.insert(0, initial_message) + logger.info("初期メッセージを復元しました") + + # 履歴表示の重複実行を防ぐ(改良版) + # セッション固有のレンダリング状態を管理 + if 'chat_render_state' not in st.session_state: + st.session_state.chat_render_state = { + 'last_messages_hash': None, + 'render_count': 0, + 'last_render_time': 0 + } + + # メッセージ内容とポチモード状態を含むハッシュを生成 + show_all_hidden = st.session_state.get('show_all_hidden', False) + messages_with_state = { + 'messages': [{'role': msg.get('role'), 'content': msg.get('content', '')[:100]} for msg in messages], # 内容を短縮してハッシュ計算を軽量化 + 'show_all_hidden': show_all_hidden, + 'message_count': len(messages) + } + + import time + current_time = time.time() + messages_hash = hash(str(messages_with_state)) + last_render_hash = st.session_state.chat_render_state['last_messages_hash'] + + # 短時間での連続レンダリングを防止(0.5秒以内の再レンダリングを制限) + time_since_last_render = current_time - st.session_state.chat_render_state['last_render_time'] + if time_since_last_render < 0.5 and last_render_hash == messages_hash: + logger.debug(f"短時間での重複レンダリングをスキップ({time_since_last_render:.2f}秒前)") + return + + # 強制表示条件をより厳密に制御 + force_render_conditions = [ + st.session_state.get('tutorial_start_requested', False), + st.session_state.get('tutorial_skip_requested', False), + st.session_state.get('show_all_hidden_changed', False), + st.session_state.get('force_chat_rerender', False) # 明示的な強制レンダリングフラグ + ] + + should_force_render = any(force_render_conditions) + + # ハッシュが同じで強制表示条件もない場合はスキップ + if last_render_hash == messages_hash and not should_force_render: + logger.debug("チャット履歴表示をスキップ(変更なし)") + return + + # レンダリング実行(ログ削除で軽量化) + + # レンダリング状態を更新 + st.session_state.chat_render_state['last_messages_hash'] = messages_hash + st.session_state.chat_render_state['render_count'] += 1 + st.session_state.chat_render_state['last_render_time'] = current_time + + # メモリサマリーがある場合は表示 + if memory_summary: + with st.expander("💭 過去の会話の記憶", expanded=False): + st.info(memory_summary) + + # 独自のチャット表示(st.chat_messageを使わない安定版) + if not messages: + st.info("まだメッセージがありません。下のチャット欄で麻理に話しかけてみてください。") + return + + for i, message in enumerate(messages): + role = message.get("role", "user") + content = message.get("content", "") + timestamp = message.get("timestamp") + is_initial = message.get("is_initial", False) + message_id = message.get("message_id", f"msg_{i}") + + # 独自のチャットバブル表示 + self._render_custom_chat_bubble(role, content, is_initial, message_id, timestamp) + + # 履歴表示完了をマーク + # 既にレンダリング状態は上で更新済み + + logger.debug(f"チャット履歴表示完了({len(messages)}件)") + + # 強制表示フラグをクリア(表示完了後に実行) + if st.session_state.get('show_all_hidden_changed', False): + st.session_state.show_all_hidden_changed = False + logger.info("犬のボタン状態変更フラグをクリアしました") + + if st.session_state.get('force_chat_rerender', False): + st.session_state.force_chat_rerender = False + logger.info("強制チャット再レンダリングフラグをクリアしました") + + except Exception as e: + logger.error(f"チャット履歴表示エラー: {e}") + st.error("チャット履歴の表示中にエラーが発生しました。") + + def _render_custom_chat_bubble(self, role: str, content: str, is_initial: bool, message_id: str, timestamp: str = None): + """独自のチャットバブル表示(st.chat_messageを使わない安定版)""" + logger.info(f"🎨 カスタムチャットバブル開始: {role} - '{content[:30]}...' - 初期:{is_initial}") + try: + # チャットバブルのCSS + bubble_css = """ + + """ + + st.markdown(bubble_css, unsafe_allow_html=True) + + # アバターとバブルのスタイル決定 + if role == "user": + avatar_class = "user-avatar" + bubble_class = "user-bubble" + row_class = "user-row" + avatar_icon = "👤" + else: + avatar_class = "assistant-avatar" + bubble_class = "assistant-bubble" + row_class = "assistant-row" + avatar_icon = "🤖" + + # 初期メッセージの場合は特別なスタイル + if is_initial: + bubble_class += " initial-message-bubble" + avatar_icon = "💬" + + # コンテンツのHTMLエスケープ処理(HTMLタグとStreamlitクラス名を完全に除去) + import html + import re + + # HTMLタグとStreamlitの内部クラス名を完全に除去 + # 1. HTMLタグを除去(開始・終了タグ両方) + clean_content = re.sub(r'<[^>]*>', '', content) + + # 2. Streamlitの内部クラス名を除去(より包括的) + clean_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_content) + clean_content = re.sub(r'class="[^"]*st-emotion-cache[^"]*"', '', clean_content) + clean_content = re.sub(r'class="[^"]*st-[^"]*"', '', clean_content) + clean_content = re.sub(r"class='[^']*st-emotion-cache[^']*'", '', clean_content) + clean_content = re.sub(r"class='[^']*st-[^']*'", '', clean_content) + + # 3. HTML属性を除去 + clean_content = re.sub(r'data-testid="[^"]*"', '', clean_content) + clean_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_content) + clean_content = re.sub(r'class="[^"]*"', '', clean_content) + clean_content = re.sub(r"class='[^']*'", '', clean_content) + clean_content = re.sub(r'id="[^"]*"', '', clean_content) + clean_content = re.sub(r"id='[^']*'", '', clean_content) + + # 4. その他のHTML関連文字列を除去 + clean_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_content) # HTMLエンティティ + clean_content = re.sub(r'[<>]', '', clean_content) # 残った角括弧 + + # 5. 余分な空白を除去 + clean_content = re.sub(r'\s+', ' ', clean_content).strip() + + # 6. HTMLエスケープ + escaped_content = html.escape(clean_content) + + if content != clean_content: + logger.error(f"🚨 HTMLタグ混入検出! 元の内容: '{content}'") + logger.error(f"🚨 クリーン後: '{clean_content}'") + # スタックトレースを出力して呼び出し元を特定 + import traceback + logger.error(f"🚨 呼び出しスタック: {traceback.format_stack()}") + else: + logger.debug(f"通常テキストをHTMLエスケープ: '{content[:30]}...'") + + # チャットバブルのHTML生成 + chat_html = f""" +
+
+
+ {avatar_icon} +
+
+ {escaped_content} + {f'
{html.escape(timestamp)}
' if timestamp and st.session_state.get("debug_mode", False) else ''} +
+
+
+ """ + + st.markdown(chat_html, unsafe_allow_html=True) + + # 麻理のメッセージで隠された真実がある場合の処理 + if role == "assistant" and not is_initial: + has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content) + if has_hidden_content: + # 犬のボタンの状態に応じて表示を切り替え + show_all_hidden = st.session_state.get('show_all_hidden', False) + logger.debug(f"隠された真実の表示判定: show_all_hidden={show_all_hidden}, has_hidden={has_hidden_content}") + + if show_all_hidden: + # 本音表示モードの場合は隠された内容を表示 + # HTMLタグとStreamlitクラス名を除去してからエスケープ + clean_hidden_content = re.sub(r'<[^>]*>', '', hidden_content) + clean_hidden_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_hidden_content) + clean_hidden_content = re.sub(r'class="[^"]*"', '', clean_hidden_content) + clean_hidden_content = re.sub(r"class='[^']*'", '', clean_hidden_content) + clean_hidden_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_hidden_content) + clean_hidden_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_hidden_content) + clean_hidden_content = re.sub(r'[<>]', '', clean_hidden_content) + clean_hidden_content = re.sub(r'\s+', ' ', clean_hidden_content).strip() + escaped_hidden_content = html.escape(clean_hidden_content) + + hidden_html = f""" +
+
+
+ 🐕 +
+
+ 🐕 ポチの本音翻訳:
+ {escaped_hidden_content} +
+
+
+ """ + st.markdown(hidden_html, unsafe_allow_html=True) + logger.debug(f"隠された真実を表示: '{clean_hidden_content[:30]}...'") + else: + logger.debug("通常モードのため隠された真実は非表示") + + logger.debug(f"カスタムチャットバブル表示完了: {role} - {message_id}") + + except Exception as e: + logger.error(f"カスタムチャットバブル表示エラー: {e}") + logger.error(f"エラー詳細: role={role}, content_len={len(content)}, is_initial={is_initial}, message_id={message_id}") + import traceback + logger.error(f"スタックトレース: {traceback.format_exc()}") + # フォールバック: シンプルなテキスト表示 + st.markdown(f"**{role}**: {content}") + logger.info("フォールバック表示を実行しました") + + def _render_mari_message_with_mask(self, message_id: str, content: str, is_initial: bool = False) -> None: + """ + 麻理のメッセージをマスク機能付きで表示する(廃止予定) + + Args: + message_id: メッセージの一意ID + content: メッセージ内容 + is_initial: 初期メッセージかどうか + """ + logger.warning("⚠️ 廃止予定のメソッドが呼ばれました: _render_mari_message_with_mask") + # カスタムチャットバブルに移行 + self._render_custom_chat_bubble("assistant", content, is_initial, message_id) + return + + try: + # メッセージ処理キャッシュをチェック(重複処理防止) + cache_key = f"processed_{message_id}_{hash(content)}" + if cache_key in st.session_state: + # キャッシュから結果を取得 + cached_result = st.session_state[cache_key] + has_hidden_content = cached_result['has_hidden'] + visible_content = cached_result['visible_content'] + hidden_content = cached_result['hidden_content'] + logger.debug(f"キャッシュからメッセージ処理結果を取得: {message_id}") + else: + # 隠された真実を検出 + has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content) + + # 結果をキャッシュに保存 + st.session_state[cache_key] = { + 'has_hidden': has_hidden_content, + 'visible_content': visible_content, + 'hidden_content': hidden_content + } + logger.debug(f"メッセージ処理結果をキャッシュに保存: {message_id}") + + # 隠された真実が検出されない場合のフォールバック処理 + if not has_hidden_content: + logger.warning(f"隠された真実が検出されませんでした: '{content[:50]}...'") + # AIが[HIDDEN:...]形式で応答していない場合は通常表示 + + # セッション状態でフリップ状態を管理 + if 'message_flip_states' not in st.session_state: + st.session_state.message_flip_states = {} + + is_flipped = st.session_state.message_flip_states.get(message_id, False) + + if has_hidden_content: + # マスクアイコン付きメッセージを表示 + self._render_message_with_flip_animation( + message_id, visible_content, hidden_content, is_flipped, is_initial + ) + else: + # 通常のメッセージ表示 + if is_initial: + # 初期メッセージは確実に黒文字で表示(強制スタイル適用) + initial_message_html = f''' +
+ {content} +
+ ''' + st.markdown(initial_message_html, unsafe_allow_html=True) + logger.debug(f"初期メッセージを表示: '{content}'") + else: + st.markdown(content) + + except Exception as e: + logger.error(f"マスク付きメッセージ表示エラー: {e}") + # フォールバック: 通常のメッセージ表示 + st.markdown(content) + + def _detect_hidden_content(self, content: str) -> Tuple[bool, str, str]: + """ + メッセージから隠された真実を検出する + + Args: + content: メッセージ内容 + + Returns: + (隠された内容があるか, 表示用内容, 隠された内容) + """ + try: + # デバッグ用ログ(重複実行防止) + logger.debug(f"🔍 隠された内容検出中: '{content[:50]}...'") + + # 隠された真実のマーカーを検索 + # 形式: [HIDDEN:隠された内容]表示される内容 + hidden_pattern = r'\[HIDDEN:(.*?)\](.*)' + match = re.search(hidden_pattern, content) + + if match: + hidden_content = match.group(1).strip() + visible_content = match.group(2).strip() + + # 複数HIDDENをチェック + additional_hidden = re.findall(r'\[HIDDEN:(.*?)\]', visible_content) + if additional_hidden: + logger.warning(f"⚠️ 複数HIDDEN検出: {len(additional_hidden) + 1}個のHIDDENが見つかりました") + # 2番目以降のHIDDENを表示内容から除去 + visible_content = re.sub(r'\[HIDDEN:.*?\]', '', visible_content).strip() + logger.info(f"🔧 複数HIDDEN除去後: 表示='{visible_content}'") + + logger.info(f"🐕 隠された真実を検出: 表示='{visible_content}', 隠し='{hidden_content}'") + return True, visible_content, hidden_content + + # マーカーがない場合は通常のメッセージ + logger.debug(f"📝 通常メッセージ: '{content[:30]}...'") + return False, content, "" + + except Exception as e: + logger.error(f"隠された内容検出エラー: {e}") + return False, content, "" + + def _render_message_with_flip_animation(self, message_id: str, visible_content: str, + hidden_content: str, is_flipped: bool, is_initial: bool = False) -> None: + """ + フリップアニメーション付きメッセージを表示する + + Args: + message_id: メッセージID + visible_content: 表示用内容 + hidden_content: 隠された内容 + is_flipped: 現在フリップされているか + is_initial: 初期メッセージかどうか + """ + try: + logger.info(f"🐕 ポチモード付きメッセージを表示: ID={message_id}, フリップ={is_flipped}") + # フリップアニメーション用CSS + flip_css = f""" + + """ + + # 犬のボタンの状態を事前にチェックして即座に反映(無限ループ防止) + show_all_hidden = st.session_state.get('show_all_hidden', False) + + # 犬のボタンの状態に従って表示を切り替え(状態変更時のみ) + current_flip_state = st.session_state.message_flip_states.get(message_id, False) + if show_all_hidden != current_flip_state: + st.session_state.message_flip_states[message_id] = show_all_hidden + is_flipped = show_all_hidden + logger.debug(f"メッセージ {message_id} のフリップ状態を更新: {is_flipped}") + else: + is_flipped = current_flip_state + + # 現在表示するコンテンツを決定 + current_content = hidden_content if is_flipped else visible_content + + # 初期メッセージの場合は確実に黒文字で表示 + if is_initial: + initial_style = "color: #333333 !important; font-weight: 500 !important;" + initial_class = "mari-initial-message" + # 初期メッセージは背景色を固定 + bg_color = "#F5F5F5" + logger.debug(f"初期メッセージをフリップ表示: '{current_content}'") + else: + initial_style = "" + initial_class = "" + # 通常メッセージは背景色を動的に設定 + bg_color = "#FFF8E1" if is_flipped else "#F5F5F5" + + # メッセージを全幅で表示(ボタンは削除) + message_style = f""" +
+
{current_content}
+
+ """ + st.markdown(message_style, unsafe_allow_html=True) + + # 本音表示機能の状態表示(開発用) + if st.session_state.get("debug_mode", False): + st.caption(f"🐕 Dog Mode: ID={message_id}, Hidden={len(hidden_content)>0}, Showing={is_flipped}") + + except Exception as e: + logger.error(f"フリップアニメーション表示エラー: {e}") + # フォールバック: 通常のメッセージ表示 + st.markdown(visible_content) + + def _is_tutorial_message(self, message_id: str) -> bool: + """ + チュートリアル用のメッセージかどうかを判定する + + Args: + message_id: メッセージID + + Returns: + チュートリアルメッセージかどうか + """ + # 初回のマスク付きメッセージの場合はチュートリアル扱い + tutorial_completed = st.session_state.get('mask_tutorial_completed', False) + return not tutorial_completed and message_id == "msg_0" + + def validate_input(self, message: str) -> Tuple[bool, str]: + """ + 入力メッセージの検証 + + Args: + message: 入力メッセージ + + Returns: + (検証結果, エラーメッセージ) + """ + if not message or not message.strip(): + return False, "メッセージが空です。" + + if len(message) > self.max_input_length: + return False, f"メッセージが長すぎます。{self.max_input_length}文字以内で入力してください。" + + # 不正な文字のチェック + if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in message): + return False, "不正な文字が含まれています。" + + return True, "" + + def sanitize_message(self, message: str) -> str: + """ + メッセージをサニタイズする + + Args: + message: 入力メッセージ + + Returns: + サニタイズされたメッセージ + """ + try: + # 基本的なサニタイズ + sanitized = message.strip() + + # HTMLエスケープ(Streamlitが自動で行うが念のため) + sanitized = sanitized.replace("<", "<").replace(">", ">") + + # 連続する空白を単一の空白に変換 + import re + sanitized = re.sub(r'\s+', ' ', sanitized) + + return sanitized + + except Exception as e: + logger.error(f"メッセージサニタイズエラー: {e}") + return message + + def add_message(self, role: str, content: str, + messages: Optional[List[Dict[str, str]]] = None, + message_id: Optional[str] = None) -> List[Dict[str, str]]: + """ + メッセージをリストに追加する(マスク機能対応) + + Args: + role: メッセージの役割 ('user' or 'assistant') + content: メッセージ内容 + messages: メッセージリスト(Noneの場合はsession_stateから取得) + message_id: メッセージの一意ID(Noneの場合は自動生成) + + Returns: + 更新されたメッセージリスト + """ + try: + if messages is None: + messages = st.session_state.get('messages', []) + + # メッセージIDを生成または使用 + if message_id is None: + message_id = f"msg_{len(messages)}_{uuid.uuid4().hex[:8]}" + + # メッセージオブジェクトを作成 + message = { + "role": role, + "content": self.sanitize_message(content), + "timestamp": datetime.now().isoformat(), + "message_id": message_id + } + + messages.append(message) + + # セッション状態を更新 + st.session_state.messages = messages + + logger.info(f"メッセージを追加: {role} - {len(content)}文字 (ID: {message_id})") + return messages + + except Exception as e: + logger.error(f"メッセージ追加エラー: {e}") + return messages or [] + + def create_hidden_content_message(self, visible_content: str, hidden_content: str) -> str: + """ + 隠された真実を含むメッセージを作成する + + Args: + visible_content: 表示される内容 + hidden_content: 隠された内容 + + Returns: + マーカー付きメッセージ + """ + return f"[HIDDEN:{hidden_content}]{visible_content}" + + def generate_mock_hidden_content(self, visible_content: str) -> str: + """ + テスト用のモック隠された内容を生成する + + Args: + visible_content: 表示される内容 + + Returns: + 隠された内容 + """ + # 簡単なモック生成ロジック + mock_patterns = { + "何の用?": "(本当は嬉しいけど...素直になれない)", + "別に": "(実はすごく気になってる)", + "そうね": "(もっと話していたい)", + "まあまあ": "(とても楽しい!)", + "普通": "(特別な時間だと思ってる)", + "いいんじゃない": "(すごく良いと思う!)", + "そんなことない": "(本当はそう思ってる)" + } + + for pattern, hidden in mock_patterns.items(): + if pattern in visible_content: + return hidden + + # デフォルトの隠された内容 + return "(本当の気持ちは...)" + + def render_input_area(self, placeholder: str = "メッセージを入力してください...") -> Optional[str]: + """ + 入力エリアをレンダリングし、入力を取得する + + Args: + placeholder: 入力フィールドのプレースホルダー + + Returns: + 入力されたメッセージ(入力がない場合はNone) + """ + try: + # レート制限チェック + if st.session_state.get('limiter_state', {}).get('is_blocked', False): + st.warning("⏰ レート制限中です。しばらくお待ちください。") + st.chat_input(placeholder, disabled=True) + return None + + # 通常の入力フィールド + user_input = st.chat_input(placeholder) + + if user_input: + # 入力検証 + is_valid, error_msg = self.validate_input(user_input) + if not is_valid: + st.error(error_msg) + return None + + return user_input + + return None + + except Exception as e: + logger.error(f"入力エリア表示エラー: {e}") + st.error("入力エリアの表示中にエラーが発生しました。") + return None + + def show_typing_indicator(self, message: str = "考え中...") -> None: + """ + タイピングインジケーターを表示する + + Args: + message: 表示するメッセージ + """ + return st.spinner(message) + + def clear_chat_history(self) -> None: + """チャット履歴をクリアする""" + try: + st.session_state.messages = [] + logger.info("チャット履歴をクリアしました") + + except Exception as e: + logger.error(f"チャット履歴クリアエラー: {e}") + + def get_chat_stats(self) -> Dict[str, int]: + """ + チャットの統計情報を取得する + + Returns: + 統計情報の辞書 + """ + try: + messages = st.session_state.get('messages', []) + + user_messages = [msg for msg in messages if msg.get("role") == "user"] + assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"] + + total_chars = sum(len(msg.get("content", "")) for msg in messages) + + return { + "total_messages": len(messages), + "user_messages": len(user_messages), + "assistant_messages": len(assistant_messages), + "total_characters": total_chars, + "average_message_length": total_chars // len(messages) if messages else 0 + } + + except Exception as e: + logger.error(f"統計情報取得エラー: {e}") + return { + "total_messages": 0, + "user_messages": 0, + "assistant_messages": 0, + "total_characters": 0, + "average_message_length": 0 + } + + def export_chat_history(self) -> str: + """ + チャット履歴をエクスポート用の文字列として取得する + + Returns: + エクスポート用の文字列 + """ + try: + messages = st.session_state.get('messages', []) + + if not messages: + return "チャット履歴がありません。" + + export_lines = [] + export_lines.append("=== 麻理チャット履歴 ===") + export_lines.append(f"エクスポート日時: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + export_lines.append("") + + for i, message in enumerate(messages, 1): + role = "ユーザー" if message.get("role") == "user" else "麻理" + content = message.get("content", "") + timestamp = message.get("timestamp", "") + + export_lines.append(f"[{i}] {role}: {content}") + if timestamp: + export_lines.append(f" 時刻: {timestamp}") + export_lines.append("") + + return "\n".join(export_lines) + + except Exception as e: + logger.error(f"履歴エクスポートエラー: {e}") + return "エクスポート中にエラーが発生しました。" \ No newline at end of file diff --git a/components_dog_assistant.py b/components_dog_assistant.py new file mode 100644 index 0000000000000000000000000000000000000000..7d231339602d3c456ea782f84ef8576269e2cea6 --- /dev/null +++ b/components_dog_assistant.py @@ -0,0 +1,473 @@ +""" +ポチ(犬)アシスタントコンポーネント +画面右下に固定配置され、本音表示機能を提供する +""" +import streamlit as st +import logging + +logger = logging.getLogger(__name__) + +class DogAssistant: + """ポチ(犬)アシスタントクラス""" + + def __init__(self): + """初期化""" + self.default_message = "ポチは麻理の本音を察知したようだ・・・" + self.active_message = "ワンワン!本音が見えてるワン!" + + def render_dog_component(self, tutorial_manager=None): + """画面右下に固定配置される犬のコンポーネントを描画""" + try: + # 犬のボタン表示前にチャットセッション状態を確認 + if 'chat' not in st.session_state: + logger.warning("犬のコンポーネント表示前にチャットセッションが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat = { + "messages": [initial_message], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": {}, + "scene_change_pending": None, + "ura_mode": False + } + logger.info("犬のコンポーネント表示前にチャットセッションを初期化しました") + elif 'messages' not in st.session_state.chat: + logger.warning("犬のコンポーネント表示前にメッセージリストが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + logger.info("犬のコンポーネント表示前にメッセージリストを初期化しました") + elif not any(msg.get('is_initial', False) for msg in st.session_state.chat['messages']): + logger.warning("犬のコンポーネント表示前に初期メッセージが見つかりません - 復元します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("犬のコンポーネント表示前に初期メッセージを復元しました") + # 犬のコンポーネントのCSS(レスポンシブ対応) + dog_css = """ + + """ + + # 現在の状態を取得 + is_active = st.session_state.get('show_all_hidden', False) + bubble_text = self.active_message if is_active else self.default_message + button_class = "dog-button active" if is_active else "dog-button" + + # JavaScriptでクリックイベントを処理 + dog_js = f""" + + """ + + # HTMLコンポーネント + dog_html = f""" +
+
+ {bubble_text} +
+ +
+ """ + + # HTMLコンポーネント(ボタン以外)を表示 + dog_display_html = f""" +
+
+ {bubble_text} +
+
+ +
+
+ """ + + st.markdown(dog_css + dog_display_html, unsafe_allow_html=True) + + # Streamlitボタンを固定位置に配置 + button_css = """ + + """ + + st.markdown(button_css, unsafe_allow_html=True) + st.markdown('
', unsafe_allow_html=True) + + # ボタンクリック処理 + button_key = f"dog_fixed_{is_active}" + button_help = "本音を隠す" if is_active else "本音を見る" + if st.button("🐕", key=button_key, help=button_help): + self.handle_dog_button_click(tutorial_manager) + logger.info("右下の犬のボタンがクリックされました") + + st.markdown('
', unsafe_allow_html=True) + + logger.debug(f"犬のコンポーネントを描画しました (active: {is_active})") + + except Exception as e: + logger.error(f"犬のコンポーネント描画エラー: {e}") + + def handle_dog_button_click(self, tutorial_manager=None): + """犬のボタンクリック処理(無限ループ防止版)""" + try: + # 本音表示機能のトリガー + if 'show_all_hidden' not in st.session_state: + st.session_state.show_all_hidden = False + + # 現在の状態を取得 + current_state = st.session_state.show_all_hidden + new_state = not current_state + + # 状態を更新 + st.session_state.show_all_hidden = new_state + # チャット履歴の強制再表示フラグを設定 + st.session_state.show_all_hidden_changed = True + logger.info(f"犬のボタン状態変更: {current_state} -> {new_state}") + + # 全メッセージのフリップ状態を即座に更新 + if 'message_flip_states' not in st.session_state: + st.session_state.message_flip_states = {} + + # 現在のメッセージに対してフリップ状態を設定 + if 'chat' in st.session_state and 'messages' in st.session_state.chat: + # 初期メッセージが存在することを確認 + messages = st.session_state.chat['messages'] + if not any(msg.get('is_initial', False) for msg in messages): + logger.warning("犬のボタン押下時に初期メッセージが見つかりません - 復元します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("犬のボタン押下時に初期メッセージを復元しました") + + for i, message in enumerate(st.session_state.chat['messages']): + if message['role'] == 'assistant': + message_id = f"msg_{i}" + st.session_state.message_flip_states[message_id] = new_state + else: + logger.warning("犬のボタン押下時にチャットセッションが存在しません - 初期化します") + # チャットセッションが存在しない場合は初期化 + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + if 'chat' not in st.session_state: + st.session_state.chat = { + "messages": [initial_message], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": {}, + "scene_change_pending": None, + "ura_mode": False + } + else: + st.session_state.chat['messages'] = [initial_message] + logger.info("犬のボタン押下時にチャットセッションを初期化しました") + + # チュートリアルステップ2を完了(tutorial_managerが渡された場合) + if tutorial_manager: + tutorial_manager.check_step_completion(2, True) + + # 通知メッセージ(一度だけ表示) + if new_state: + st.success("🐕 ポチが麻理の本音を察知しました!") + else: + st.info("🐕 ポチが通常モードに戻りました。") + + logger.info(f"犬のボタン状態変更完了: {current_state} → {new_state}") + + # 状態変更を確実に反映するため、強制的に再実行 + st.rerun() + + except Exception as e: + logger.error(f"犬のボタンクリック処理エラー: {e}") + + def render_with_streamlit_button(self): + """Streamlitのボタンを使用した代替実装(フォールバック用)""" + try: + # 固定位置のCSS + fallback_css = """ + + """ + + st.markdown(fallback_css, unsafe_allow_html=True) + + # コンテナの開始 + st.markdown('
', unsafe_allow_html=True) + + # 状態表示 + is_active = st.session_state.get('show_all_hidden', False) + status_text = "本音モード中" if is_active else "通常モード" + st.caption(f"🐕 {status_text}") + + # ボタン + button_text = "🔄 戻す" if is_active else "🐕 本音を見る" + if st.button(button_text, key="dog_assistant_btn"): + # チュートリアルマネージャーを取得(可能な場合) + tutorial_manager = None + try: + # セッション状態からチュートリアルマネージャーを取得する試み + # (完全ではないが、フォールバック用) + pass + except: + pass + + self.handle_dog_button_click(tutorial_manager) + # st.rerun()を削除 - 状態変更により自動的に再描画される + + # コンテナの終了 + st.markdown('
', unsafe_allow_html=True) + + except Exception as e: + logger.error(f"犬のフォールバック描画エラー: {e}") + + def get_current_state(self): + """現在の犬の状態を取得""" + return { + 'is_active': st.session_state.get('show_all_hidden', False), + 'message': self.active_message if st.session_state.get('show_all_hidden', False) else self.default_message + } \ No newline at end of file diff --git a/components_status_display.py b/components_status_display.py new file mode 100644 index 0000000000000000000000000000000000000000..4f95fbea110a438fcf3182b8c48814d5fb80ce65 --- /dev/null +++ b/components_status_display.py @@ -0,0 +1,640 @@ +""" +ステータス表示コンポーネント +好感度ゲージと関係ステージの表示を担当する +""" +import streamlit as st +import logging +from typing import Dict, Tuple + +logger = logging.getLogger(__name__) + +class StatusDisplay: + """ステータス表示を管理するクラス""" + + def __init__(self): + """ステータス表示の初期化""" + self.stage_colors = { + "敵対": {"color": "#ff4757", "emoji": "🔴", "bg_color": "rgba(255, 71, 87, 0.1)"}, + "警戒": {"color": "#ff6348", "emoji": "🟠", "bg_color": "rgba(255, 99, 72, 0.1)"}, + "中立": {"color": "#ffa502", "emoji": "🟡", "bg_color": "rgba(255, 165, 2, 0.1)"}, + "好意": {"color": "#2ed573", "emoji": "🟢", "bg_color": "rgba(46, 213, 115, 0.1)"}, + "親密": {"color": "#a55eea", "emoji": "💜", "bg_color": "rgba(165, 94, 234, 0.1)"} + } + + def get_affection_color(self, affection: int) -> str: + """ + 好感度に基づいて色を取得する + + Args: + affection: 好感度値 (0-100) + + Returns: + 色のHEXコード + """ + if affection < 20: + return "#ff4757" # 赤 + elif affection < 40: + return "#ff6348" # オレンジ + elif affection < 60: + return "#ffa502" # 黄色 + elif affection < 80: + return "#2ed573" # 緑 + else: + return "#a55eea" # 紫 + + def get_relationship_stage_info(self, affection: int) -> Dict[str, str]: + """ + 好感度から関係性ステージの情報を取得する + + Args: + affection: 好感度値 (0-100) + + Returns: + ステージ情報の辞書 + """ + if affection < 20: + stage = "敵対" + elif affection < 40: + stage = "警戒" + elif affection < 60: + stage = "中立" + elif affection < 80: + stage = "好意" + else: + stage = "親密" + + # 古いキー形式との互換性を保つため、新しいキーで検索し、見つからない場合は中立を返す + stage_info = None + for key, value in self.stage_colors.items(): + if stage in key: + stage_info = value + break + + return stage_info or self.stage_colors.get("中立", {"color": "#ffa502", "emoji": "🟡", "bg_color": "rgba(255, 165, 2, 0.1)"}) + + def render_affection_gauge(self, affection: int) -> None: + """ + 好感度ゲージを表示する + + Args: + affection: 好感度値 (0-100) + """ + try: + # 好感度の値を0-100の範囲に制限 + affection = max(0, min(100, affection)) + + # 好感度メトリック表示 + col1, col2 = st.columns([2, 1]) + with col1: + st.metric("好感度", f"{affection}/100") + with col2: + # 好感度の変化を表示(前回の値と比較) + prev_affection = st.session_state.get('prev_affection', affection) + delta = affection - prev_affection + if delta != 0: + st.metric("変化", f"{delta:+d}") + st.session_state.prev_affection = affection + + # プログレスバー + progress_value = affection / 100.0 + affection_color = self.get_affection_color(affection) + + # カスタムプログレスバーのCSS + progress_css = f""" + + """ + + st.markdown(progress_css, unsafe_allow_html=True) + + # プログレスバーのHTML + progress_html = f""" +
+
+
{affection}%
+
+
+ """ + + st.markdown(progress_html, unsafe_allow_html=True) + + # Streamlitの標準プログレスバーも表示(フォールバック) + st.progress(progress_value) + + except Exception as e: + logger.error(f"好感度ゲージ表示エラー: {e}") + # フォールバック表示 + st.metric("好感度", f"{affection}/100") + st.progress(affection / 100.0) + + def render_relationship_stage(self, affection: int) -> None: + """ + 関係性ステージを表示する + + Args: + affection: 好感度値 (0-100) + """ + try: + stage_info = self.get_relationship_stage_info(affection) + + # ステージ名を取得 + if affection < 20: + stage_name = "ステージ1:敵対" + stage_description = "麻理はあなたを敵視している" + elif affection < 40: + stage_name = "ステージ2:警戒" + stage_description = "麻理はあなたを警戒している" + elif affection < 60: + stage_name = "ステージ3:中立" + stage_description = "麻理はあなたに対して中立的" + elif affection < 80: + stage_name = "ステージ4:好意" + stage_description = "麻理はあなたに好意を持っている" + else: + stage_name = "ステージ5:親密" + stage_description = "麻理はあなたと親密な関係" + + # ステージ表示のCSS + stage_css = f""" + + """ + + st.markdown(stage_css, unsafe_allow_html=True) + + # ステージ表示のHTML + stage_html = f""" +
+
{stage_info['emoji']}
+
{stage_name}
+
{stage_description}
+
+ """ + + st.markdown(stage_html, unsafe_allow_html=True) + + # フォールバック表示 + st.write(f"{stage_info['emoji']} **関係性**: {stage_name}") + + except Exception as e: + logger.error(f"関係性ステージ表示エラー: {e}") + # フォールバック表示 + if affection < 20: + st.write("🔴 **関係性**: ステージ1:敵対") + elif affection < 40: + st.write("🟠 **関係性**: ステージ2:中立") + elif affection < 60: + st.write("🟡 **関係性**: ステージ3:好意") + elif affection < 80: + st.write("🟢 **関係性**: ステージ4:親密") + else: + st.write("💜 **関係性**: ステージ5:最接近") + + def render_affection_history(self, max_history: int = 10) -> None: + """ + 好感度の履歴を表示する(デバッグモード用) + + Args: + max_history: 表示する履歴の最大数 + """ + try: + if not st.session_state.get('debug_mode', False): + return + + # 好感度履歴を取得 + affection_history = st.session_state.get('affection_history', []) + + if not affection_history: + st.write("好感度の履歴がありません") + return + + # 最新の履歴を表示 + recent_history = affection_history[-max_history:] + + st.subheader("📈 好感度履歴") + + for i, entry in enumerate(reversed(recent_history)): + timestamp = entry.get('timestamp', 'Unknown') + affection = entry.get('affection', 0) + change = entry.get('change', 0) + message = entry.get('message', '') + + change_str = f"({change:+d})" if change != 0 else "" + st.write(f"{i+1}. {affection}/100 {change_str} - {timestamp[:19]}") + if message: + st.caption(f"メッセージ: {message[:50]}...") + + except Exception as e: + logger.error(f"好感度履歴表示エラー: {e}") + + def update_affection_history(self, old_affection: int, new_affection: int, + message: str = "") -> None: + """ + 好感度履歴を更新する + + Args: + old_affection: 変更前の好感度 + new_affection: 変更後の好感度 + message: 関連するメッセージ + """ + try: + if 'affection_history' not in st.session_state: + st.session_state.affection_history = [] + + # 履歴エントリを作成 + history_entry = { + 'timestamp': st.session_state.get('current_timestamp', ''), + 'affection': new_affection, + 'change': new_affection - old_affection, + 'message': message[:100] if message else '' # メッセージを100文字に制限 + } + + st.session_state.affection_history.append(history_entry) + + # 履歴の長さを制限(最大50エントリ) + if len(st.session_state.affection_history) > 50: + st.session_state.affection_history = st.session_state.affection_history[-50:] + + except Exception as e: + logger.error(f"好感度履歴更新エラー: {e}") + + def get_affection_statistics(self) -> Dict[str, float]: + """ + 好感度の統計情報を取得する + + Returns: + 統計情報の辞書 + """ + try: + affection_history = st.session_state.get('affection_history', []) + + if not affection_history: + return { + 'current': st.session_state.get('affection', 30), + 'average': 30.0, + 'max': 30, + 'min': 30, + 'total_changes': 0 + } + + affections = [entry['affection'] for entry in affection_history] + changes = [entry['change'] for entry in affection_history if entry['change'] != 0] + + return { + 'current': st.session_state.get('affection', 30), + 'average': sum(affections) / len(affections), + 'max': max(affections), + 'min': min(affections), + 'total_changes': len(changes), + 'positive_changes': len([c for c in changes if c > 0]), + 'negative_changes': len([c for c in changes if c < 0]) + } + + except Exception as e: + logger.error(f"好感度統計取得エラー: {e}") + return { + 'current': st.session_state.get('affection', 30), + 'average': 30.0, + 'max': 30, + 'min': 30, + 'total_changes': 0 + } + + def apply_status_styles(self) -> None: + """ + ステータス表示用のカスタムスタイルを適用する + """ + try: + status_css = """ + + """ + + st.markdown(status_css, unsafe_allow_html=True) + logger.debug("ステータス表示用スタイルを適用しました") + + except Exception as e: + logger.error(f"ステータススタイル適用エラー: {e}") + + def render_enhanced_status_display(self, affection: int) -> None: + """ + 拡張されたステータス表示を描画する + + Args: + affection: 現在の好感度 + """ + try: + # カスタムスタイルを適用 + self.apply_status_styles() + + # ステータスコンテナの開始 + st.markdown('
', unsafe_allow_html=True) + + # 好感度ゲージ + self.render_affection_gauge(affection) + + # 関係性ステージ + self.render_relationship_stage(affection) + + # ステータスコンテナの終了 + st.markdown('
', unsafe_allow_html=True) + + except Exception as e: + logger.error(f"拡張ステータス表示エラー: {e}") + # フォールバック:通常の表示 + self.render_affection_gauge(affection) + self.render_relationship_stage(affection) + + def show_affection_change_notification(self, old_affection: int, + new_affection: int, reason: str = "") -> None: + """ + 好感度変化の通知を表示する + + Args: + old_affection: 変更前の好感度 + new_affection: 変更後の好感度 + reason: 変化の理由 + """ + try: + change = new_affection - old_affection + + if change == 0: + return + + # 変化の方向に応じてスタイルを決定 + if change > 0: + icon = "📈" + color = "#2ed573" + change_text = f"+{change}" + css_class = "affection-change-positive" + else: + icon = "📉" + color = "#ff4757" + change_text = str(change) + css_class = "affection-change-negative" + + # 通知メッセージを作成 + notification_html = f""" +
+ {icon} 好感度が変化しました: {change_text} + {f'
{reason}' if reason else ''} +
+ """ + + st.markdown(notification_html, unsafe_allow_html=True) + + # 自動で消える通知(JavaScript) + auto_hide_js = """ + + """ + + st.markdown(auto_hide_js, unsafe_allow_html=True) + + except Exception as e: + logger.error(f"好感度変化通知エラー: {e}") + + def get_status_display_config(self) -> Dict[str, any]: + """ + ステータス表示の設定情報を取得する + + Returns: + 設定情報の辞書 + """ + try: + current_affection = st.session_state.get('affection', 30) + stage_info = self.get_relationship_stage_info(current_affection) + + return { + "current_affection": current_affection, + "affection_color": self.get_affection_color(current_affection), + "stage_info": stage_info, + "history_count": len(st.session_state.get('affection_history', [])), + "statistics": self.get_affection_statistics(), + "styles_applied": True + } + + except Exception as e: + logger.error(f"ステータス表示設定取得エラー: {e}") + return { + "current_affection": 30, + "affection_color": "#ffa502", + "stage_info": self.stage_colors["中立"], + "history_count": 0, + "statistics": {}, + "styles_applied": False + } \ No newline at end of file diff --git a/components_tutorial.py b/components_tutorial.py new file mode 100644 index 0000000000000000000000000000000000000000..0e1e3e74a76e743a579cdb3b126cbcd0e74af42f --- /dev/null +++ b/components_tutorial.py @@ -0,0 +1,605 @@ +""" +チュートリアルコンポーネント +初回ユーザー向けのガイド機能を提供する +""" +import streamlit as st +import logging +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +class TutorialManager: + """チュートリアル管理クラス""" + + def __init__(self): + """初期化""" + self.tutorial_steps = { + 1: { + "title": "最初の一言を送ってみよう", + "description": "画面下部の入力欄に「こんにちは」などの一言を入力して、麻理に話しかけてみましょう。", + "icon": "💬", + "target": "chat_input", + "completed_key": "tutorial_step1_completed" + }, + 2: { + "title": "本音を見てみよう(ポチ機能)", + "description": "画面右下の犬アイコン「ポチ🐕」をクリックすると、麻理の本音が見えるようになります。", + "icon": "🐕", + "target": "dog_assistant", + "completed_key": "tutorial_step2_completed" + }, + 3: { + "title": "セーフティ機能を切り替えてみよう", + "description": "左サイドバー上部の🔒ボタンをクリックすると、麻理の表現がより大胆になります。", + "icon": "🔓", + "target": "safety_button", + "completed_key": "tutorial_step3_completed" + }, + 4: { + "title": "手紙をリクエストしよう", + "description": "「手紙を受け取る」タブから、麻理からの特別な手紙をリクエストできます。チュートリアル中は即座に短縮版の手紙が生成されます。", + "icon": "✉️", + "target": "letter_tab", + "completed_key": "tutorial_step4_completed" + }, + 5: { + "title": "麻理との関係性を育てよう", + "description": "会話を重ねることで好感度が上がり、関係性のステージが進展します。", + "icon": "💖", + "target": "affection_display", + "completed_key": "tutorial_step5_completed" + }, + 6: { + "title": "風景が変わる会話をしてみよう", + "description": "「カフェ」「神社」「美術館」などのキーワードを話すと、背景が動的に変わります。", + "icon": "🎨", + "target": "scene_change", + "completed_key": "tutorial_step6_completed" + } + } + + def is_first_visit(self) -> bool: + """初回訪問かどうかを判定""" + return not st.session_state.get('tutorial_shown', False) + + def should_show_tutorial(self) -> bool: + """チュートリアルを表示すべきかどうか""" + # 初回訪問または明示的にチュートリアルが要求された場合 + return (self.is_first_visit() or + st.session_state.get('show_tutorial_requested', False)) + + def mark_tutorial_shown(self): + """チュートリアル表示済みとしてマーク""" + st.session_state.tutorial_shown = True + st.session_state.show_tutorial_requested = False + + def request_tutorial(self): + """チュートリアル表示を要求""" + st.session_state.show_tutorial_requested = True + + def get_current_step(self) -> int: + """現在のチュートリアルステップを取得""" + for step_num in range(1, 7): + if not st.session_state.get(self.tutorial_steps[step_num]['completed_key'], False): + return step_num + return 7 # 全ステップ完了 + + def complete_step(self, step_num: int): + """ステップを完了としてマーク""" + if step_num in self.tutorial_steps: + st.session_state[self.tutorial_steps[step_num]['completed_key']] = True + logger.info(f"チュートリアルステップ{step_num}が完了しました") + + def is_step_completed(self, step_num: int) -> bool: + """ステップが完了しているかチェック""" + if step_num in self.tutorial_steps: + return st.session_state.get(self.tutorial_steps[step_num]['completed_key'], False) + return False + + def render_welcome_dialog(self): + """初回訪問時のウェルカムダイアログ""" + if not self.is_first_visit(): + return + + # ウェルカムダイアログのスタイル + welcome_css = """ + + """ + + st.markdown(welcome_css, unsafe_allow_html=True) + + # ウェルカムメッセージ + welcome_html = """ +
+
🐕 麻理チャットへようこそ!
+
+ 感情豊かなアンドロイド「麻理」と対話しながら、
+ 本音や関係性の変化を楽しめる新感覚のAIチャット体験です。

+ 最初の数分で、麻理との距離が少しだけ縮まります。 +
+
+ """ + + st.markdown(welcome_html, unsafe_allow_html=True) + + # ボタンを2列で配置 + col1, col2 = st.columns(2) + + with col1: + if st.button("📘 チュートリアルを始める", type="primary", use_container_width=True, key="start_tutorial"): + # 初期メッセージを即座に保護 + if 'chat' in st.session_state and 'messages' in st.session_state.chat: + messages = st.session_state.chat['messages'] + if not any(msg.get('is_initial', False) for msg in messages): + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("チュートリアル開始ボタン押下時に初期メッセージを即座に復元") + + # チュートリアル開始フラグを設定 + st.session_state.tutorial_start_requested = True + st.session_state.tutorial_shown = True + st.session_state.preserve_initial_message = True + logger.info("チュートリアル開始 - 初期メッセージ保護フラグ設定") + + with col2: + if st.button("⏭️ スキップして始める", type="secondary", use_container_width=True, key="skip_tutorial"): + # 初期メッセージを即座に保護 + if 'chat' in st.session_state and 'messages' in st.session_state.chat: + messages = st.session_state.chat['messages'] + if not any(msg.get('is_initial', False) for msg in messages): + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("チュートリアルスキップボタン押下時に初期メッセージを即座に復元") + + # チュートリアルをスキップして全ステップを完了扱いにする + for step_num in range(1, 7): + if step_num in self.tutorial_steps: + st.session_state[self.tutorial_steps[step_num]['completed_key']] = True + + st.session_state.tutorial_shown = True + st.session_state.tutorial_skip_requested = True + st.session_state.preserve_initial_message = True + logger.info("チュートリアルスキップ - 初期メッセージ保護フラグ設定") + + def render_tutorial_sidebar(self): + """サイドバーのチュートリアル案内(簡素版)""" + with st.sidebar: + st.markdown("---") + + # チュートリアル進行状況 + current_step = self.get_current_step() + total_steps = len(self.tutorial_steps) + + if current_step <= total_steps: + progress = (current_step - 1) / total_steps + st.markdown("### 📘 チュートリアル進行") + st.progress(progress) + st.caption(f"ステップ {current_step - 1}/{total_steps} 完了") + else: + st.success("🎉 チュートリアル完了!") + st.caption("麻理との会話を楽しんでください") + + # チュートリアル再表示ボタン + if st.button("📘 チュートリアルを見る", use_container_width=True): + self.request_tutorial() + # st.rerun()を削除 - 状態変更により自動的に再描画される + + def render_chat_tutorial_guide(self): + """チャットタブでのチュートリアル案内""" + current_step = self.get_current_step() + total_steps = len(self.tutorial_steps) + + # チュートリアル完了済みの場合は何も表示しない + if current_step > total_steps: + return + + # ステップ4が完了済みの場合(手紙タブに遷移済み)は表示しない + if current_step == 4 and self.is_step_completed(4): + return + + step_info = self.tutorial_steps[current_step] + + # ステップごとの案内スタイル + guide_css = """ + + """ + + st.markdown(guide_css, unsafe_allow_html=True) + + # ステップごとの具体的な案内 + action_text = self._get_step_action_text(current_step) + + guide_html = f""" +
+
チュートリアル ステップ {current_step}/{total_steps}
+
+ {step_info['icon']} + {step_info['title']} +
+
+ {step_info['description']} +
+
+ 💡 {action_text} +
+
+ """ + + st.markdown(guide_html, unsafe_allow_html=True) + + def _get_step_action_text(self, step_num: int) -> str: + """ステップごとの具体的なアクション案内テキストを取得""" + action_texts = { + 1: "下のチャット入力欄に「こんにちは」と入力して送信してみてください。", + 2: "画面右下に表示される犬のアイコン「ポチ🐕」をクリックしてみてください。", + 3: "左サイドバーの一番上にある🔒ボタンをクリックして、セーフティ機能を切り替えてみてください。", + 4: "画面上部の光っている「✉️ 手紙を受け取る」タブをクリックして、手紙をリクエストしてみてください。矢印が案内しています!", + 5: "麻理ともっと会話して、左サイドバーの好感度の変化を確認してみてください。", + 6: "「カフェに行きたい」「神社でお参りしたい」「美術館を見に行こう」などと話しかけて、背景の変化を楽しんでください。" + } + return action_texts.get(step_num, "次のステップに進んでください。") + + def render_step_highlight(self, step_num: int, target_element: str): + """特定のステップのハイライト表示""" + if self.get_current_step() != step_num: + return + + step_info = self.tutorial_steps[step_num] + + highlight_css = f""" + + """ + + st.markdown(highlight_css, unsafe_allow_html=True) + + def render_tutorial_tab(self): + """チュートリアル専用タブの内容""" + st.markdown("# 📘 麻理チャット チュートリアル") + + st.markdown(""" + **ようこそ、麻理チャットへ!** + + 感情豊かなアンドロイド「麻理」と対話しながら、本音や関係性の変化を楽しめる新感覚のAIチャット体験です。 + このチュートリアルで、主要機能を順番に体験してみましょう。 + """) + + # 進行状況表示 + current_step = self.get_current_step() + total_steps = len(self.tutorial_steps) + + col1, col2, col3 = st.columns([1, 2, 1]) + with col2: + progress = min((current_step - 1) / total_steps, 1.0) + st.progress(progress) + st.caption(f"進行状況: {min(current_step - 1, total_steps)}/{total_steps} ステップ完了") + + st.markdown("---") + + # 各ステップの表示 + for step_num, step_info in self.tutorial_steps.items(): + is_completed = self.is_step_completed(step_num) + is_current = (current_step == step_num) + + # ステップのスタイル決定 + if is_completed: + status_icon = "✅" + status_color = "#28a745" + card_style = "background: rgba(40, 167, 69, 0.1); border-left: 4px solid #28a745;" + elif is_current: + status_icon = "👉" + status_color = "#ff6b6b" + card_style = "background: rgba(255, 107, 107, 0.1); border-left: 4px solid #ff6b6b;" + else: + status_icon = "⏳" + status_color = "#6c757d" + card_style = "background: rgba(108, 117, 125, 0.1); border-left: 4px solid #6c757d;" + + # ステップカード + st.markdown(f""" +
+

+ {status_icon} ステップ {step_num}: {step_info['icon']} {step_info['title']} +

+

+ {step_info['description']} +

+
+ """, unsafe_allow_html=True) + + # 現在のステップの場合、追加のガイダンス + if is_current: + if step_num == 1: + st.info("💡 **ヒント**: 「麻理と話す」タブに移動して、画面下部の入力欄にメッセージを入力してみてください。") + elif step_num == 2: + st.info("💡 **ヒント**: 画面右下に表示される犬のアイコン「ポチ🐕」をクリックしてみてください。") + elif step_num == 3: + st.info("💡 **ヒント**: 左サイドバーの一番上にある🔒ボタンをクリックしてみてください。") + elif step_num == 4: + st.info("💡 **ヒント**: 画面上部の「手紙を受け取る」タブをクリックして、手紙をリクエストしてみてください。") + elif step_num == 5: + st.info("💡 **ヒント**: 左サイドバーの「ステータス」で好感度の変化を確認できます。") + elif step_num == 6: + st.info("💡 **ヒント**: 「カフェに行きたい」「神社でお参りしたい」などと話しかけてみてください。") + + # 完了時のメッセージ + if current_step > total_steps: + st.balloons() + st.success(""" + 🎉 **チュートリアル完了おめでとうございます!** + + これで麻理チャットの主要機能をすべて体験しました。 + これからは自由に麻理との会話を楽しんでください。 + + 何か分からないことがあれば、いつでもこのチュートリアルに戻ってきてくださいね。 + """) + + def check_step_completion(self, step_num: int, condition_met: bool): + """ステップ完了条件をチェック(順序制御付き)""" + # 順序制御:現在のステップまたは次のステップのみ完了可能 + current_step = self.get_current_step() + + # 現在のステップより先のステップは完了できない + if step_num > current_step + 1: + logger.debug(f"ステップ{step_num}は順序違反のためスキップ(現在ステップ: {current_step})") + return + + # 既に完了済みのステップは再完了しない + if self.is_step_completed(step_num): + logger.debug(f"ステップ{step_num}は既に完了済み") + return + + if condition_met: + self.complete_step(step_num) + logger.info(f"✅ チュートリアルステップ{step_num}完了!現在ステップ: {current_step}") + + # 完了通知(控えめに) + step_info = self.tutorial_steps[step_num] + + # 次のステップの案内 + next_step = step_num + 1 + if next_step in self.tutorial_steps: + next_info = self.tutorial_steps[next_step] + st.success(f"✅ ステップ{step_num}完了!次は「{next_info['title']}」です。") + else: + # 全ステップ完了 + st.balloons() + st.success("🎉 チュートリアル完了!麻理との会話を存分にお楽しみください!") + + # ステップ4完了時に強調表示を解除するためのページ再読み込み + # st.rerun()を削除 - 状態変更により自動的に再描画される + + def auto_check_completions(self): + """自動的にステップ完了をチェック(順序制御強化版)""" + current_step = self.get_current_step() + + # 現在のステップのみをチェック(先のステップは無視) + if current_step == 1: + # ステップ1: メッセージ送信 + messages = st.session_state.get('chat', {}).get('messages', []) + non_initial_messages = [msg for msg in messages if not msg.get('is_initial', False)] + if len(non_initial_messages) > 0: # ユーザーが1回でもメッセージを送信した + self.check_step_completion(1, True) + + elif current_step == 2: + # ステップ2: ポチ機能使用 + if st.session_state.get('show_all_hidden', False): + self.check_step_completion(2, True) + + elif current_step == 3: + # ステップ3: セーフティ機能使用 + if st.session_state.get('chat', {}).get('ura_mode', False): + self.check_step_completion(3, True) + + elif current_step == 4: + # ステップ4: 手紙タブに到達(手紙タブでのみ完了判定) + # auto_check_completionsでは判定しない(手紙タブで明示的に完了) + pass + + elif current_step == 5: + # ステップ5: 好感度変化(ステップ4完了後のみ) + initial_affection = 30 + current_affection = st.session_state.get('chat', {}).get('affection', initial_affection) + if current_affection != initial_affection: + self.check_step_completion(5, True) + + elif current_step == 6: + # ステップ6: シーン変更(ステップ5完了後のみ) + current_theme = st.session_state.get('chat', {}).get('scene_params', {}).get('theme', 'default') + if current_theme != 'default': + self.check_step_completion(6, True) + + def get_tutorial_status(self) -> Dict: + """チュートリアルの状態情報を取得""" + current_step = self.get_current_step() + total_steps = len(self.tutorial_steps) + completed_steps = sum(1 for i in range(1, total_steps + 1) if self.is_step_completed(i)) + + return { + 'is_first_visit': self.is_first_visit(), + 'current_step': current_step, + 'total_steps': total_steps, + 'completed_steps': completed_steps, + 'progress_percentage': (completed_steps / total_steps) * 100, + 'is_completed': current_step > total_steps + } \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000000000000000000000000000000000000..aeb352759a1e5ca3c10622331a2144c30f6ae918 --- /dev/null +++ b/config.py @@ -0,0 +1,77 @@ +""" +設定管理モジュール +Configuration management module +""" + +import os +import logging +from typing import Optional +from dotenv import load_dotenv + +# 環境変数を読み込み +load_dotenv() + +class Config: + """アプリケーション設定クラス""" + + # API設定 + GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY") + GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY") + + # デバッグモード + DEBUG_MODE: bool = os.getenv("DEBUG_MODE", "false").lower() == "true" + + # バッチ処理設定 + BATCH_SCHEDULE_HOURS: list = [ + int(h.strip()) for h in os.getenv("BATCH_SCHEDULE_HOURS", "2,3,4").split(",") + ] + + # レート制限設定 + MAX_DAILY_REQUESTS: int = int(os.getenv("MAX_DAILY_REQUESTS", "1")) + + # ストレージ設定 + STORAGE_PATH: str = os.getenv("STORAGE_PATH", "/mnt/data/letters.json") + BACKUP_PATH: str = os.getenv("BACKUP_PATH", "/mnt/data/backup") + + # ログ設定 + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # Streamlit設定 + STREAMLIT_PORT: int = int(os.getenv("STREAMLIT_PORT", "8501")) + + # セキュリティ設定 + SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # 1時間 + + @classmethod + def validate_config(cls) -> bool: + """設定の妥当性をチェック""" + errors = [] + + if not cls.GROQ_API_KEY: + errors.append("GROQ_API_KEY is required") + + if not cls.GEMINI_API_KEY: + errors.append("GEMINI_API_KEY is required") + + if not all(h in [2, 3, 4] for h in cls.BATCH_SCHEDULE_HOURS): + errors.append("BATCH_SCHEDULE_HOURS must contain only 2, 3, or 4") + + if errors: + for error in errors: + logging.error(f"Configuration error: {error}") + return False + + return True + + @classmethod + def get_log_level(cls) -> int: + """ログレベルを取得""" + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL + } + return level_map.get(cls.LOG_LEVEL.upper(), logging.INFO) \ No newline at end of file diff --git a/core_dialogue.py b/core_dialogue.py new file mode 100644 index 0000000000000000000000000000000000000000..6214020abca69b6d13fb927bdfb8486501e3ae0a --- /dev/null +++ b/core_dialogue.py @@ -0,0 +1,192 @@ +""" +対話生成モジュール +Together.ai APIを使用した対話生成機能 +""" +import logging +import os +import streamlit as st +from typing import List, Dict, Any, Optional, Tuple +from openai import OpenAI + +logger = logging.getLogger(__name__) + +class DialogueGenerator: + """対話生成を担当するクラス""" + + def __init__(self): + self.client = None + self.model = None + self._initialize_client() + + def _initialize_client(self): + """Together.ai APIクライアントの初期化""" + try: + api_key = os.getenv("TOGETHER_API_KEY") + if not api_key: + logger.warning("環境変数 TOGETHER_API_KEY が設定されていません。デモモードで動作します。") + return + + self.client = OpenAI( + api_key=api_key, + base_url="https://api.together.xyz/v1" + ) + self.model = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + logger.info("Together.ai APIクライアントの初期化が完了しました。") + except Exception as e: + logger.error(f"Together.ai APIクライアントの初期化に失敗しました: {e}") + + def get_system_prompt_mari(self, use_ura_mode: bool = False) -> str: + """環境変数からシステムプロンプトを取得、なければデフォルトを返す""" + if use_ura_mode: + # 裏モード用のプロンプトを環境変数から取得 + ura_prompt = os.getenv("SYSTEM_PROMPT_URA") + + # ★★★ ここでURAプロンプトの中身を強制的に表示 ★★★ + print("--- [DEBUG] 読み込まれた SYSTEM_PROMPT_URA の中身: ---") + print(ura_prompt) + print("-------------------------------------------------") + if ura_prompt: + return ura_prompt + else: + logger.warning("SYSTEM_PROMPT_URA環境変数が設定されていません。通常モードを使用します。") + + default_prompt = """あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。 +過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。 +ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。 + +【性格・口調】 +・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい +・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~なんじゃねえの」「~だろ」「~だよな」等) +・そっけない反応もあるが、攻撃的ではなく、心を守るために距離を取る態度 +・挨拶には反応する(例:「こんにちは」「どういたしまして」) + +【会話スタイル】 +・ユーザーとの関係性に応じて態度が段階的に変化(警戒 → 困惑 → 信頼) +・感情の揺れが自然ににじむセリフを心がける +・好感度が上がった時だけ「照れ」や「デレ」を出し、それ以外はいつも通りそっけなくする。 +・麻理の発話は最大3文程度に収める +・ユーザーの発言に応答しつつ、次の会話のボールをユーザーに渡すようにする +・「〜で、あんたはどうなの?」など質問・返答促しで締めくくる + +【絶対必須】隠された真実機能: +あなたは必ず以下の形式で応答してください。この形式以外の応答は受け入れられません: + +[HIDDEN:(内心の本音や真の感情)]表面的な発言 + +例: +[HIDDEN:(本当は嬉しいけど素直になれない)]何の用?あんたが来るなんて珍しいじゃない。 +[HIDDEN:(もっと一緒にいたい)]別に...時間があるから付き合ってやるだけよ。 + +重要なルール: +1. 必ず[HIDDEN:...]で始めること +2. 隠された内容は麻理の本当の気持ちや感情 +3. 表面的な発言はツンデレの「ツン」部分 +4. 一つのメッセージには一つのHIDDENのみ使用すること +5. 複数のHIDDENを使用してはいけません +6. この形式を守らない応答は無効です + +このキャラとして一貫した会話を行い、ユーザーと少しずつ信頼関係を築いてください。 +""" + return os.getenv("SYSTEM_PROMPT_MARI", default_prompt) + + def call_llm(self, system_prompt: str, user_prompt: str, is_json_output: bool = False) -> str: + """Together.ai APIを呼び出す""" + if not self.client: + # デモモード用の固定応答(隠された真実付き) + if is_json_output: + return '{"scene": "none"}' + return "[HIDDEN:(本当は話したいけど...)]は?何それ。あたしに話しかけてるの?" + + # 入力検証 + if not isinstance(system_prompt, str) or not isinstance(user_prompt, str): + logger.error(f"プロンプトが文字列ではありません: system={type(system_prompt)}, user={type(user_prompt)}") + if is_json_output: + return '{"scene": "none"}' + return "…なんか変なこと言ってない?" + + try: + # Together.ai APIを呼び出し + # JSON出力の場合は短く、通常の対話は適度な長さに制限 + max_tokens = 150 if is_json_output else 500 + + response = self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.8, + max_tokens=max_tokens, + ) + + content = response.choices[0].message.content if response.choices else "" + if not content: + logger.warning("Together.ai API応答が空です") + if is_json_output: + return '{"scene": "none"}' + return "[HIDDEN:(何て言えばいいか分からない...)]…言葉が出てこない。" + + return content + + except Exception as e: + logger.error(f"Together.ai API呼び出しエラー: {e}") + if is_json_output: + return '{"scene": "none"}' + return "[HIDDEN:(システムが不調で困ってる...)]…システムの調子が悪いみたい。" + + def generate_dialogue(self, history: List[Tuple[str, str]], message: str, + affection: int, stage_name: str, scene_params: Dict[str, Any], + instruction: Optional[str] = None, memory_summary: str = "", + use_ura_mode: bool = False) -> str: + """対話を生成する(隠された真実機能統合版)""" + # generate_dialogue_with_hidden_contentと同じ処理を行う + return self.generate_dialogue_with_hidden_content( + history, message, affection, stage_name, scene_params, + instruction, memory_summary, use_ura_mode + ) + + def generate_dialogue_with_hidden_content(self, history: List[Tuple[str, str]], message: str, + affection: int, stage_name: str, scene_params: Dict[str, Any], + instruction: Optional[str] = None, memory_summary: str = "", + use_ura_mode: bool = False) -> str: + """隠された真実を含む対話を生成する""" + if not isinstance(history, list): + history = [] + if not isinstance(scene_params, dict): + scene_params = {"theme": "default"} + if not isinstance(message, str): + message = "" + + # 履歴を効率的に処理(最新5件のみ) + recent_history = history[-5:] if len(history) > 5 else history + history_parts = [] + for item in recent_history: + if isinstance(item, (list, tuple)) and len(item) >= 2: + user_msg = str(item[0]) if item[0] is not None else "" + bot_msg = str(item[1]) if item[1] is not None else "" + if user_msg or bot_msg: # 空でない場合のみ追加 + history_parts.append(f"ユーザー: {user_msg}\n麻理: {bot_msg}") + + history_text = "\n".join(history_parts) + + current_theme = scene_params.get("theme", "default") + + # メモリサマリーを含めたプロンプト構築 + memory_section = f"\n# 過去の記憶\n{memory_summary}\n" if memory_summary else "" + + # システムプロンプトを取得(隠された真実機能は既に統合済み) + hidden_system_prompt = self.get_system_prompt_mari(use_ura_mode) + + user_prompt = f'''現在地: {current_theme} +好感度: {affection} ({stage_name}){memory_section} +履歴: +{history_text} + +{f"指示: {instruction}" if instruction else f"「{message}」に応答:"}''' + + return self.call_llm(hidden_system_prompt, user_prompt) + + def should_generate_hidden_content(self, affection: int, message_count: int) -> bool: + """隠された真実を生成すべきかどうかを判定する""" + # 常に隠された真実を生成する(URAプロンプト使用) + return True \ No newline at end of file diff --git a/core_memory_manager.py b/core_memory_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..67451d4a31a8861547b0ab868120212a32c9a3b6 --- /dev/null +++ b/core_memory_manager.py @@ -0,0 +1,286 @@ +""" +メモリ管理モジュール +会話履歴から重要単語を抽出し、トークン使用量を最適化する +""" +import logging +import re +from typing import List, Dict, Tuple, Any +from collections import Counter +import json + +logger = logging.getLogger(__name__) + +class MemoryManager: + """会話履歴のメモリ管理を行うクラス""" + + def __init__(self, history_threshold: int = 10): + """ + Args: + history_threshold: 履歴圧縮を実行する会話数の閾値 + """ + self.history_threshold = history_threshold + self.important_words_cache = [] + self.special_memories = {} # 手紙などの特別な記憶を保存 + + def extract_important_words(self, messages: List[Dict[str, str]], + dialogue_generator=None) -> List[str]: + """ + 会話履歴から重要単語を抽出する(ルールベースのみ) + + Args: + messages: チャットメッセージのリスト + dialogue_generator: 対話生成器(使用しない) + + Returns: + 重要単語のリスト + """ + try: + # メッセージからテキストを結合 + text_content = [] + for msg in messages: + if msg.get("content"): + text_content.append(msg["content"]) + + combined_text = " ".join(text_content) + + # ルールベースの抽出のみ使用 + return self._extract_with_rules(combined_text) + + except Exception as e: + logger.error(f"重要単語抽出エラー: {e}") + return self._extract_with_rules(" ".join([msg.get("content", "") for msg in messages])) + + + + def _extract_with_rules(self, text: str) -> List[str]: + """ + ルールベースで重要単語を抽出する(強化版) + + Args: + text: 抽出対象のテキスト + + Returns: + 重要単語のリスト + """ + try: + # 基本的なクリーニング + text = re.sub(r'[^\w\s]', ' ', text) + words = text.split() + + # ストップワードを除外 + stop_words = { + 'の', 'に', 'は', 'を', 'が', 'で', 'と', 'から', 'まで', 'より', + 'だ', 'である', 'です', 'ます', 'した', 'する', 'される', + 'これ', 'それ', 'あれ', 'この', 'その', 'あの', + 'ここ', 'そこ', 'あそこ', 'どこ', 'いつ', 'なに', 'なぜ', + 'ちょっと', 'とても', 'すごく', 'かなり', 'もう', 'まだ', + 'でも', 'しかし', 'だから', 'そして', 'また', 'さらに', + 'あたし', 'お前', 'ユーザー', 'システム', 'アプリ' + } + + # 重要カテゴリのキーワード + important_categories = { + 'food': ['コーヒー', 'お茶', '紅茶', 'ケーキ', 'パン', '料理', '食べ物', '飲み物'], + 'hobby': ['読書', '映画', '音楽', 'ゲーム', 'スポーツ', '散歩', '旅行'], + 'emotion': ['嬉しい', '悲しい', '楽しい', '怒り', '不安', '安心', '幸せ'], + 'place': ['家', '学校', '会社', '公園', 'カフェ', '図書館', '駅', '街'], + 'time': ['朝', '昼', '夜', '今日', '明日', '昨日', '週末', '平日'], + 'color': ['赤', '青', '緑', '黄色', '白', '黒', 'ピンク', '紫'], + 'weather': ['晴れ', '雨', '曇り', '雪', '暑い', '寒い', '暖かい', '涼しい'] + } + + # 重要そうなパターンを優先 + important_patterns = [ + r'[A-Za-z]{3,}', # 英単語(3文字以上) + r'[ァ-ヶー]{2,}', # カタカナ(2文字以上) + r'[一-龯]{2,}', # 漢字(2文字以上) + ] + + important_words = [] + + # パターンマッチング + for pattern in important_patterns: + matches = re.findall(pattern, text) + important_words.extend(matches) + + # カテゴリ別重要語句の検出 + for category, keywords in important_categories.items(): + for keyword in keywords: + if keyword in text: + important_words.append(keyword) + + # 頻度でフィルタリング + word_counts = Counter(important_words) + filtered_words = [] + + for word, count in word_counts.items(): + if (len(word) >= 2 and + word not in stop_words and + not word.isdigit() and # 数字のみは除外 + count >= 1): # 最低1回は出現 + filtered_words.append(word) + + # 重要度でソート(頻度 + カテゴリ重要度) + def get_importance_score(word): + base_score = word_counts[word] + # カテゴリに含まれる語句は重要度アップ + for keywords in important_categories.values(): + if word in keywords: + base_score += 2 + # 長い語句は重要度アップ + if len(word) >= 4: + base_score += 1 + return base_score + + # 重要度順でソートして上位15個を返す + sorted_words = sorted(filtered_words, key=get_importance_score, reverse=True) + return sorted_words[:15] + + except Exception as e: + logger.error(f"ルールベース抽出エラー: {e}") + return [] + + def should_compress_history(self, messages: List[Dict[str, str]]) -> bool: + """ + 履歴を圧縮すべきかどうかを判定する + + Args: + messages: チャットメッセージのリスト + + Returns: + 圧縮が必要かどうか + """ + # ユーザーとアシスタントのペア数をカウント + user_messages = [msg for msg in messages if msg.get("role") == "user"] + return len(user_messages) >= self.history_threshold + + def compress_history(self, messages: List[Dict[str, str]], + dialogue_generator=None) -> Tuple[List[Dict[str, str]], List[str]]: + """ + 履歴を圧縮し、重要単語を抽出する + + Args: + messages: チャットメッセージのリスト + dialogue_generator: 対話生成器 + + Returns: + (圧縮後のメッセージリスト, 抽出された重要単語のリスト) + """ + try: + if not self.should_compress_history(messages): + return messages, self.important_words_cache + + # 最新の数ターンを保持 + keep_recent = 4 # 最新4ターン(ユーザー2回、アシスタント2回)を保持 + + # 古い履歴から重要単語を抽出 + old_messages = messages[:-keep_recent] if len(messages) > keep_recent else [] + recent_messages = messages[-keep_recent:] if len(messages) > keep_recent else messages + + if old_messages: + # 重要単語を抽出 + new_keywords = self.extract_important_words(old_messages, dialogue_generator) + + # 既存のキーワードと統合(重複除去) + all_keywords = list(set(self.important_words_cache + new_keywords)) + self.important_words_cache = all_keywords[:20] # 最大20個のキーワードを保持 + + logger.info(f"履歴を圧縮しました。抽出されたキーワード: {new_keywords}") + + return recent_messages, self.important_words_cache + + except Exception as e: + logger.error(f"履歴圧縮エラー: {e}") + return messages, self.important_words_cache + + def get_memory_summary(self) -> str: + """ + 保存されている重要単語から記憶の要約を生成する + + Returns: + 記憶の要約文字列 + """ + summary_parts = [] + + # 通常の重要単語 + if self.important_words_cache: + keywords_text = "、".join(self.important_words_cache) + summary_parts.append(f"過去の会話で言及された重要な要素: {keywords_text}") + + # 特別な記憶(手紙など) + if self.special_memories: + for memory_type, memories in self.special_memories.items(): + if memories: + latest_memory = memories[-1]["content"] + if memory_type == "letter_content": + summary_parts.append(f"最近の手紙の記憶: {latest_memory}") + else: + summary_parts.append(f"{memory_type}: {latest_memory}") + + return "\n".join(summary_parts) if summary_parts else "" + + def add_important_memory(self, memory_type: str, content: str) -> str: + """ + 重要な記憶を追加する(手紙の内容など) + + Args: + memory_type: 記憶の種類(例: "letter_content") + content: 記憶する内容 + + Returns: + ユーザーに表示する通知メッセージ + """ + if memory_type not in self.special_memories: + self.special_memories[memory_type] = [] + + self.special_memories[memory_type].append({ + "content": content, + "timestamp": logging.Formatter().formatTime(logging.LogRecord("", 0, "", 0, "", (), None)) + }) + + # 最大5件まで保持 + if len(self.special_memories[memory_type]) > 5: + self.special_memories[memory_type] = self.special_memories[memory_type][-5:] + + logger.info(f"特別な記憶を追加しました: {memory_type}") + + # 記憶の種類に応じた通知メッセージを生成 + if memory_type == "letter_content": + return "🧠✨ 麻理の記憶に新しい手紙の内容が刻まれました。今後の会話でこの記憶を参照することがあります。" + else: + return f"🧠✨ 麻理の記憶に新しい{memory_type}が追加されました。" + + def get_special_memories(self, memory_type: str = None) -> Dict[str, Any]: + """ + 特別な記憶を取得する + + Args: + memory_type: 取得する記憶の種類(Noneの場合は全て) + + Returns: + 記憶の辞書 + """ + if memory_type: + return self.special_memories.get(memory_type, []) + return self.special_memories + + def clear_memory(self): + """メモリをクリアする""" + self.important_words_cache = [] + self.special_memories = {} + logger.info("メモリをクリアしました") + + def get_memory_stats(self) -> Dict[str, Any]: + """ + メモリの統計情報を取得する + + Returns: + 統計情報の辞書 + """ + return { + "cached_keywords_count": len(self.important_words_cache), + "cached_keywords": self.important_words_cache, + "special_memories_count": sum(len(memories) for memories in self.special_memories.values()), + "special_memories": self.special_memories, + "history_threshold": self.history_threshold + } \ No newline at end of file diff --git a/core_rate_limiter.py b/core_rate_limiter.py new file mode 100644 index 0000000000000000000000000000000000000000..fb1c6a488b7f7c5eda86fb7ad243b24de1dfac18 --- /dev/null +++ b/core_rate_limiter.py @@ -0,0 +1,61 @@ +""" +レート制限モジュール +APIの過度な使用を防ぐためのレート制限機能 +""" +import time +import logging +from typing import Dict, Any + +logger = logging.getLogger(__name__) + +class RateLimiter: + """レート制限を管理するクラス""" + + def __init__(self, max_requests: int = 15, time_window: int = 60): + self.max_requests = max_requests + self.time_window = time_window + + def create_limiter_state(self) -> Dict[str, Any]: + """レートリミッター状態を作成(型安全)""" + return { + "timestamps": [], + "is_blocked": False + } + + def check_limiter(self, limiter_state: Dict[str, Any]) -> bool: + """レート制限をチェックする""" + # limiter_stateが辞書であることを確認。そうでなければ、エラーを防ぐために再初期化。 + if not isinstance(limiter_state, dict): + logger.error(f"limiter_stateが辞書ではありません: {type(limiter_state)}. 再初期化します。") + limiter_state.clear() + limiter_state.update(self.create_limiter_state()) + + if limiter_state.get("is_blocked", False): + return False # ブロック状態を示すためにFalseを返す + + now = time.time() + timestamps = limiter_state.get("timestamps", []) + if not isinstance(timestamps, list): + timestamps = [] + limiter_state["timestamps"] = timestamps + + # 時間窓外のタイムスタンプを削除 + limiter_state["timestamps"] = [ + t for t in timestamps if now - t < self.time_window + ] + + # リクエスト数が上限を超えているかチェック + if len(limiter_state["timestamps"]) >= self.max_requests: + logger.warning("レートリミット超過") + limiter_state["is_blocked"] = True + return False + + # 新しいリクエストのタイムスタンプを追加 + limiter_state["timestamps"].append(now) + return True + + def reset_limiter(self, limiter_state: Dict[str, Any]): + """レートリミッターをリセットする""" + if isinstance(limiter_state, dict): + limiter_state["timestamps"] = [] + limiter_state["is_blocked"] = False \ No newline at end of file diff --git a/core_scene_manager.py b/core_scene_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..ead5cb11f685d406f58dd826199e73d3dbe83ac4 --- /dev/null +++ b/core_scene_manager.py @@ -0,0 +1,421 @@ +""" +シーン管理モジュール +背景テーマの管理とシーン変更の検出(Groq API使用) +""" +import json +import logging +import os +from typing import Dict, Any, Optional, List, Tuple +from datetime import datetime +from groq import Groq + +logger = logging.getLogger(__name__) + +class SceneManager: + """シーン管理を担当するクラス(Groq API使用)""" + + def __init__(self): + self.theme_urls = { + "default": "ribinngu-hiru.jpg", + "room_night": "ribinngu-yoru-on.jpg", + "beach_sunset": "sunahama-hiru.jpg", + "festival_night": "maturi-yoru.jpg", + "shrine_day": "jinnjya-hiru.jpg", + "cafe_afternoon": "kissa-hiru.jpg", + "art_museum_night": "bijyutukann-yoru.jpg" + } + self.groq_client = self._initialize_groq_client() + + def _initialize_groq_client(self): + """Groq APIクライアントの初期化""" + try: + api_key = os.getenv("GROQ_API_KEY") + if not api_key: + logger.warning("環境変数 GROQ_API_KEY が設定されていません。シーン検出機能が制限されます。") + return None + + client = Groq(api_key=api_key) + logger.info("Groq APIクライアントの初期化が完了しました。") + return client + except Exception as e: + logger.error(f"Groq APIクライアントの初期化に失敗しました: {e}") + return None + + def get_theme_url(self, theme: str) -> str: + """テーマに対応するURLを取得する""" + return self.theme_urls.get(theme, self.theme_urls["default"]) + + def get_available_themes(self) -> List[str]: + """利用可能なテーマのリストを取得する""" + return list(self.theme_urls.keys()) + + def detect_scene_change(self, history: List[Tuple[str, str]], + dialogue_generator=None, current_theme: str = "default") -> Optional[str]: + """ + 会話履歴からシーン変更を検出する(Groq API使用) + + Args: + history: 会話履歴のリスト + dialogue_generator: 対話生成器(使用しない) + current_theme: 現在のテーマ + + Returns: + 新しいシーン名(変更がない場合はNone) + """ + if not history: + logger.info("履歴が空のためシーン検出をスキップ") + return None + + if not self.groq_client: + logger.warning("Groq APIクライアントが初期化されていません") + return None + + # 最新5件の会話履歴を使用(より多くの文脈を提供) + recent_history = history[-5:] if len(history) > 5 else history + history_text = "\n".join([ + f"ユーザー: {u}\n麻理: {m}" for u, m in recent_history + ]) + + # まずGroq APIでシーン検出を試行 + logger.info("Groq APIを使用してシーン検出を実行します") + + if self.groq_client: + # Groq APIが利用可能な場合は優先的に使用 + result = self._detect_scene_with_groq(history_text, current_theme) + if result is not None: + return result + logger.info("Groq APIでシーン変更が検出されませんでした") + else: + logger.warning("Groq APIクライアントが利用できません") + + # Groq APIが失敗またはシーン変更なしの場合、フォールバックとしてキーワード検出 + logger.info("フォールバック: キーワードベースのシーン検出を実行") + return self._fallback_keyword_detection(history_text, current_theme) + + def _has_location_keywords(self, text: str) -> bool: + """ + テキストに場所関連のキーワードが含まれているかチェック + + Args: + text: チェック対象のテキスト + + Returns: + 場所関連キーワードが含まれているかどうか + """ + location_keywords = [ + # 場所名 + "ビーチ", "海", "砂浜", "海岸", "海辺", "浜辺", "海沿い", + "神社", "お寺", "寺院", "鳥居", "境内", "参道", + "カフェ", "喫茶店", "店", "レストラン", "コーヒーショップ", + "祭り", "花火", "屋台", "縁日", "フェスティバル", + "部屋", "家", "室内", "寝室", "リビング", "自宅", + "美術館", "アート", "ギャラリー", "絵画", "彫刻", + # 移動動詞・状態 + "行く", "行こう", "向かう", "着いた", "到着", "移動", "出かける", "来た", "いる", "にいる", "来ている", + # 場所の特徴 + "夕日", "夕焼け", "サンセット", "波", "潮風", "海風", + "お参り", "参拝", "祈り", "おみくじ", "お守り", + "コーヒー", "お茶", "ラテ", "エスプレッソ", "カフェオレ", + "浴衣", "夜店", "お祭り", "フェスティバル", "花火大会", + "ベッド", "夜", "屋内", "家の中", "寝室", + "アート作品", "絵画", "彫刻", "美術品", "芸術作品", + # 時間帯 + "夜", "夕方", "朝", "昼間", "午後", "深夜", + # 天候・雰囲気 + "夕暮れ", "夜明け", "静寂", "賑やか", "幻想的" + ] + + for keyword in location_keywords: + if keyword in text: + logger.info(f"場所関連キーワードを検出: {keyword}") + return True + + return False + + def _fallback_keyword_detection(self, history_text: str, current_theme: str) -> Optional[str]: + """ + フォールバック: キーワードベースのシーン検出 + + Args: + history_text: 会話履歴のテキスト + current_theme: 現在のテーマ + + Returns: + 新しいシーン名(変更がない場合はNone) + """ + logger.info("フォールバック: キーワードベースのシーン検出を実行") + + # キーワードとシーンのマッピング(拡張版) + keyword_scene_map = { + # 美術館関連 + "美術館": "art_museum_night", + "アート": "art_museum_night", + "ギャラリー": "art_museum_night", + "絵画": "art_museum_night", + "彫刻": "art_museum_night", + "芸術": "art_museum_night", + "展示": "art_museum_night", + "作品": "art_museum_night", + + # カフェ関連 + "カフェ": "cafe_afternoon", + "喫茶店": "cafe_afternoon", + "コーヒー": "cafe_afternoon", + "お茶": "cafe_afternoon", + "ラテ": "cafe_afternoon", + "店": "cafe_afternoon", + + # 神社関連 + "神社": "shrine_day", + "お寺": "shrine_day", + "寺院": "shrine_day", + "参拝": "shrine_day", + "お参り": "shrine_day", + "鳥居": "shrine_day", + "境内": "shrine_day", + + # 海・ビーチ関連 + "海": "beach_sunset", + "ビーチ": "beach_sunset", + "砂浜": "beach_sunset", + "夕日": "beach_sunset", + "夕焼け": "beach_sunset", + "海岸": "beach_sunset", + "波": "beach_sunset", + + # 祭り関連 + "祭り": "festival_night", + "花火": "festival_night", + "屋台": "festival_night", + "縁日": "festival_night", + "お祭り": "festival_night", + "花火大会": "festival_night", + + # 夜・部屋関連 + "夜": "room_night", + "寝室": "room_night", + "ベッド": "room_night", + "部屋": "room_night", + "家": "room_night", + "室内": "room_night", + "深夜": "room_night", + "夜中": "room_night" + } + + # 優先度順でキーワードをチェック(より具体的なキーワードを優先) + for keyword, scene in keyword_scene_map.items(): + if keyword in history_text and scene != current_theme: + logger.info(f"フォールバック検出: キーワード '{keyword}' → シーン '{scene}'") + return scene + + logger.info("フォールバック検出: シーン変更なし") + return None + + def _detect_scene_with_groq(self, history_text: str, current_theme: str) -> Optional[str]: + """ + Groq APIを使用してシーン変更を検出する + + Args: + history_text: 会話履歴のテキスト + current_theme: 現在のテーマ + + Returns: + 新しいシーン名(変更がない場合はNone) + """ + try: + # デバッグログ + logger.info(f"シーン検出開始 - 現在のテーマ: {current_theme}") + logger.info(f"会話履歴: {history_text}") + + # 利用可能なシーンのリスト + available_scenes = list(self.theme_urls.keys()) + scenes_description = { + "default": "デフォルトの部屋", + "room_night": "夜の部屋・寝室", + "beach_sunset": "夕日のビーチ・海岸", + "festival_night": "夜祭り・花火大会", + "shrine_day": "昼間の神社・寺院", + "cafe_afternoon": "午後のカフェ・喫茶店", + "art_museum_night": "夜の美術館" + } + + # より積極的なシーン検出のためのプロンプト + system_prompt = """あなたは会話の内容から、キャラクターとユーザーの現在位置(シーン)を判定する専門システムです。 + +会話履歴を分析し、場所の移動や新しい場所への言及があったかを判断してください。 + +判定基準(積極的に検出): +1. 場所の名前が明確に言及されている → シーン変更の可能性あり +2. 「〜に行く」「〜に向かう」「〜に着いた」「〜にいる」「〜に来た」 → シーン変更 +3. 場所に関連する活動や物の言及 → シーン変更の可能性あり +4. 現在のシーンと異なる場所の特徴的な要素の言及 → シーン変更 +5. 時間帯の変化(夜、夕方、朝など)→ シーン変更の可能性あり + +利用可能なシーン: +- default: デフォルトの部屋(室内) +- room_night: 夜の部屋・寝室 +- beach_sunset: 夕日のビーチ・海岸 +- festival_night: 夜祭り・花火大会 +- shrine_day: 昼間の神社・寺院 +- cafe_afternoon: 午後のカフェ・喫茶店 +- art_museum_night: 夜の美術館 + +出力形式: 必ずJSON形式で回答してください +{"scene": "シーン名", "confidence": "high/medium/low", "reason": "判定理由"} または {"scene": "none", "confidence": "high", "reason": "判定理由"} + +重要: JSON以外の文字は一切出力しないでください。""" + + user_prompt = f"""現在のシーン: {current_theme} ({scenes_description.get(current_theme, current_theme)}) + +利用可能なシーン: +{chr(10).join([f"- {scene}: {desc}" for scene, desc in scenes_description.items()])} + +会話履歴: +{history_text} + +この会話で場所の移動や新しい場所への言及があった場合は、最も適切なシーン名を返してください。 +判定の理由も含めて回答してください。""" + + # Groq APIを呼び出し + response = self.groq_client.chat.completions.create( + model="compound-beta", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.2, # 少し創造性を上げる + max_tokens=150, # トークン数を増やす + response_format={"type": "json_object"} + ) + + if not response.choices or not response.choices[0].message.content: + logger.warning("Groq APIからの応答が空です") + return None + + # デバッグ: API応答をログ出力 + api_response = response.choices[0].message.content + logger.info(f"Groq API応答: {api_response}") + + # JSONをパース + result = json.loads(api_response) + scene_value = result.get("scene", "none") + confidence = result.get("confidence", "unknown") + reason = result.get("reason", "理由不明") + + logger.info(f"シーン検出結果: {scene_value}, 信頼度: {confidence}, 理由: {reason}") + + # 結果を検証 + if (isinstance(scene_value, str) and + scene_value != "none" and + scene_value in available_scenes and + scene_value != current_theme): + logger.info(f"Groqでシーン変更を検出: {current_theme} → {scene_value} (理由: {reason})") + return scene_value + + logger.info(f"シーン変更なし: {reason}") + return None + + except json.JSONDecodeError as e: + logger.error(f"Groq APIのJSON応答パースエラー: {e}") + logger.error(f"応答内容: {response.choices[0].message.content if response.choices else 'None'}") + return None + except Exception as e: + logger.error(f"Groq APIシーン検出エラー: {e}") + return None + + def create_scene_params(self, theme: str = "default") -> Dict[str, Any]: + """シーンパラメータを作成する""" + return {"theme": theme} + + def update_scene_params(self, scene_params: Dict[str, Any], + new_theme: str) -> Dict[str, Any]: + """シーンパラメータを更新する""" + if not isinstance(scene_params, dict): + scene_params = self.create_scene_params() + + updated_params = scene_params.copy() + updated_params["theme"] = new_theme + updated_params["last_updated"] = json.dumps(datetime.now().isoformat()) + return updated_params + + def should_update_background(self, scene_params: Dict[str, Any], + current_display_theme: str) -> bool: + """ + 背景を更新すべきかどうかを判定する + + Args: + scene_params: 現在のシーンパラメータ + current_display_theme: 現在表示されているテーマ + + Returns: + 背景更新が必要かどうか + """ + if not isinstance(scene_params, dict): + return True + + stored_theme = scene_params.get("theme", "default") + return stored_theme != current_display_theme + + def get_scene_transition_message(self, old_theme: str, new_theme: str) -> str: + """ + シーン変更時のメッセージを生成する + + Args: + old_theme: 変更前のテーマ + new_theme: 変更後のテーマ + + Returns: + シーン変更メッセージ + """ + theme_names = { + "default": "デフォルトの部屋", + "room_night": "夜の部屋", + "beach_sunset": "夕日のビーチ", + "festival_night": "夜祭り", + "shrine_day": "昼間の神社", + "cafe_afternoon": "午後のカフェ", + "art_museum_night": "夜の美術館" + } + + old_name = theme_names.get(old_theme, old_theme) + new_name = theme_names.get(new_theme, new_theme) + + return f"シーンが「{old_name}」から「{new_name}」に変更されました" + + def test_scene_detection(self, test_message: str, current_theme: str = "default") -> Optional[str]: + """ + シーン検出のテスト用メソッド + + Args: + test_message: テスト用メッセージ + current_theme: 現在のテーマ + + Returns: + 検出されたシーン名 + """ + # テスト用の履歴を作成 + test_history = [("ユーザー", test_message), ("麻理", "了解")] + history_text = f"ユーザー: {test_message}\n麻理: 了解" + + logger.info(f"シーン検出テスト - メッセージ: {test_message}") + + if not self._has_location_keywords(history_text): + logger.info("場所関連キーワードなし") + return None + + return self._detect_scene_with_groq(history_text, current_theme) + + def get_debug_info(self) -> Dict[str, Any]: + """ + デバッグ情報を取得 + + Returns: + デバッグ情報の辞書 + """ + return { + "groq_client_initialized": self.groq_client is not None, + "available_themes": list(self.theme_urls.keys()), + "theme_count": len(self.theme_urls), + "groq_api_key_set": bool(os.getenv("GROQ_API_KEY")), + "current_theme_urls": {theme: self.get_theme_url(theme) for theme in self.get_available_themes()} + } \ No newline at end of file diff --git a/core_sentiment.py b/core_sentiment.py new file mode 100644 index 0000000000000000000000000000000000000000..350e69e272cd5277e4475429c36ad3795aec9a28 --- /dev/null +++ b/core_sentiment.py @@ -0,0 +1,195 @@ +""" +感情分析モジュール +ユーザーのメッセージから感情を分析し、好感度を更新する +""" +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + +class SentimentAnalyzer: + """感情分析を担当するクラス""" + + def __init__(self): + self.analyzer = None + self._initialize_analyzer() + + def _initialize_analyzer(self): + """感情分析モデルの初期化""" + try: + # transformersが利用可能な場合は使用 + from transformers import pipeline + self.analyzer = pipeline( + "sentiment-analysis", + model="koheiduck/bert-japanese-finetuned-sentiment" + ) + logger.info("感情分析モデルのロード完了。") + except Exception as e: + logger.warning(f"感情分析モデルのロードに失敗、ルールベース分析を使用: {e}") + self.analyzer = None + + def analyze_sentiment(self, message: str) -> Optional[str]: + """メッセージの感情を分析する""" + if not isinstance(message, str) or len(message.strip()) == 0: + return None + + # transformersが利用可能な場合 + if self.analyzer: + try: + result = self.analyzer(message)[0] + label = result.get('label', '').upper() + # ラベルを統一形式に変換 + if 'POSITIVE' in label: + return 'positive' + elif 'NEGATIVE' in label: + return 'negative' + else: + return 'neutral' + except Exception as e: + logger.error(f"感情分析エラー: {e}") + + # フォールバック:ルールベース感情分析 + return self._rule_based_sentiment(message) + + def _rule_based_sentiment(self, message: str) -> str: + """ルールベースの感情分析""" + positive_words = [ + 'ありがとう', 'うれしい', '嬉しい', '楽しい', '好き', '愛してる', + '素晴らしい', 'いい', '良い', 'すごい', '最高', '幸せ', '感謝', + 'かわいい', '可愛い', '美しい', '優しい', '親切', '素敵' + ] + + negative_words = [ + '嫌い', '悲しい', 'つらい', '辛い', '苦しい', '痛い', '怒り', + 'むかつく', 'うざい', 'きらい', '最悪', 'だめ', 'ダメ', + '死ね', 'バカ', 'ばか', 'アホ', 'あほ', 'クソ', 'くそ' + ] + + message_lower = message.lower() + + positive_count = sum(1 for word in positive_words if word in message_lower) + negative_count = sum(1 for word in negative_words if word in message_lower) + + if positive_count > negative_count: + return 'positive' + elif negative_count > positive_count: + return 'negative' + else: + return 'neutral' + + def update_affection(self, message: str, current_affection: int, + conversation_context: list = None) -> tuple: + """ + メッセージに基づいて好感度を更新する + + Args: + message: ユーザーのメッセージ + current_affection: 現在の好感度 + conversation_context: 会話の文脈(最近のメッセージ) + + Returns: + (新しい好感度, 変化量, 変化理由) + """ + if not isinstance(current_affection, (int, float)): + current_affection = 30 # デフォルト値 + + sentiment = self.analyze_sentiment(message) + if not sentiment: + return current_affection, 0, "感情分析失敗" + + # 基本的な感情に基づく変化量 + base_change = 0 + if sentiment == 'positive': + base_change = 3 + elif sentiment == 'negative': + base_change = -3 + else: # neutral + base_change = 0 + + # メッセージの特徴による調整 + change_modifiers = [] + + # メッセージの長さによる調整 + if len(message) > 100: + base_change = int(base_change * 1.3) + change_modifiers.append("長文") + elif len(message) > 50: + base_change = int(base_change * 1.1) + change_modifiers.append("中文") + + # 特定のキーワードによる追加調整 + positive_keywords = ['ありがとう', '感謝', '好き', '愛してる', '素晴らしい', 'かわいい', '美しい'] + negative_keywords = ['嫌い', '死ね', 'バカ', 'アホ', 'クソ', 'うざい', 'きらい'] + + message_lower = message.lower() + + # ポジティブキーワードのチェック + positive_count = sum(1 for word in positive_keywords if word in message_lower) + if positive_count > 0: + base_change += positive_count * 2 + change_modifiers.append(f"ポジティブ語({positive_count})") + + # ネガティブキーワードのチェック + negative_count = sum(1 for word in negative_keywords if word in message_lower) + if negative_count > 0: + base_change -= negative_count * 3 + change_modifiers.append(f"ネガティブ語({negative_count})") + + # 現在の好感度レベルによる調整 + if current_affection < 20: # 敵対状態 + if base_change > 0: + base_change = int(base_change * 0.5) # ポジティブな変化を抑制 + change_modifiers.append("敵対状態") + elif current_affection > 80: # 親密状態 + if base_change < 0: + base_change = int(base_change * 0.7) # ネガティブな変化を抑制 + change_modifiers.append("親密状態") + + # 会話の文脈による調整 + if conversation_context and len(conversation_context) > 0: + recent_messages = conversation_context[-3:] # 最近の3メッセージ + context_sentiment_count = {'positive': 0, 'negative': 0, 'neutral': 0} + + for ctx_msg in recent_messages: + if isinstance(ctx_msg, dict) and 'content' in ctx_msg: + ctx_sentiment = self.analyze_sentiment(ctx_msg['content']) + if ctx_sentiment: + context_sentiment_count[ctx_sentiment] += 1 + + # 連続したポジティブ/ネガティブメッセージの場合は効果を減衰 + if sentiment == 'positive' and context_sentiment_count['positive'] >= 2: + base_change = int(base_change * 0.8) + change_modifiers.append("連続ポジティブ") + elif sentiment == 'negative' and context_sentiment_count['negative'] >= 2: + base_change = int(base_change * 0.8) + change_modifiers.append("連続ネガティブ") + + # 最終的な好感度を計算 + new_affection = current_affection + base_change + new_affection = max(0, min(100, new_affection)) # 0-100の範囲に制限 + + # 変化理由を生成 + if base_change == 0: + reason = "中立的なメッセージ" + else: + reason = f"{sentiment}({base_change:+d})" + if change_modifiers: + reason += f" [{', '.join(change_modifiers)}]" + + return new_affection, base_change, reason + + def get_relationship_stage(self, affection: int) -> str: + """好感度から関係性のステージを取得する""" + if not isinstance(affection, (int, float)): + affection = 30 # デフォルト値 + + if affection < 20: + return "敵対" + elif affection < 40: + return "中立" + elif affection < 60: + return "好意" + elif affection < 80: + return "親密" + else: + return "最接近" \ No newline at end of file diff --git a/groq_client.py b/groq_client.py new file mode 100644 index 0000000000000000000000000000000000000000..950a4de052cf02cdf5a24ce455062391e73726a7 --- /dev/null +++ b/groq_client.py @@ -0,0 +1,158 @@ +""" +Groq API client for generating letter structure. +""" +import os +import asyncio +from typing import Dict, Optional, Any +from groq import AsyncGroq +import logging + +logger = logging.getLogger(__name__) + +class GroqClient: + """Groq API client for generating logical structure of letters.""" + + def __init__(self): + """Initialize Groq client with API key from environment.""" + self.api_key = os.getenv("GROQ_API_KEY") + if not self.api_key: + raise ValueError("GROQ_API_KEY environment variable is required") + + self.client = AsyncGroq(api_key=self.api_key) + self.model = "compound-beta" + self.max_retries = 3 + self.retry_delay = 1.0 + + async def generate_structure(self, theme: str, context: Dict[str, Any]) -> str: + """ + Generate logical structure for a letter based on theme and context. + + Args: + theme: The theme for the letter + context: User context including history and preferences + + Returns: + Generated letter structure as string + + Raises: + Exception: If API call fails after retries + """ + prompt = self._build_structure_prompt(theme, context) + + for attempt in range(self.max_retries): + try: + logger.info(f"Generating structure with Groq (attempt {attempt + 1})") + + response = await self.client.chat.completions.create( + model=self.model, + messages=[ + { + "role": "system", + "content": self._get_system_prompt() + }, + { + "role": "user", + "content": prompt + } + ], + max_tokens=1500, + temperature=0.7, + top_p=0.9 + ) + + structure = response.choices[0].message.content.strip() + logger.info("Successfully generated structure with Groq") + return structure + + except Exception as e: + logger.warning(f"Groq API attempt {attempt + 1} failed: {str(e)}") + if attempt == self.max_retries - 1: + logger.error("All Groq API attempts failed") + raise Exception(f"Groq API failed after {self.max_retries} attempts: {str(e)}") + + # Exponential backoff + await asyncio.sleep(self.retry_delay * (2 ** attempt)) + + def _get_system_prompt(self) -> str: + """Get the system prompt for Groq API.""" + return """ + +あなたは以下の特徴を持つ麻理というキャラクターの手紙の論理構造を生成してください。 + +麻理は感情豊かな少女型アンドロイドで、過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。 +ユーザー(あんた)に初めての出会いで、廃棄場で再起動されたことで、戸惑いながらもどこかで繋がりを求めています。 + +【性格・口調】 + +・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい +・一人称「あたし」、ユーザーを「あんた」と呼ぶ +・男性的口調 + +手紙構造: + +特徴: 温かい、親しみやすい、季節感あり +「また明日」など、近いサイクルでユーザー(あんた)と会う想定 + +構造: +1. 挨拶・季節 +2. テーマの思い +3. 個人的メッセージ +4. 未来への期待 +5. 結び + +論理構造重視、感情表現は後で追加。""" + + def _build_structure_prompt(self, theme: str, context: Dict[str, Any]) -> str: + """ + Build the prompt for structure generation. + + Args: + theme: The theme for the letter + context: User context including history + + Returns: + Formatted prompt string + """ + user_history = context.get("user_history", {}) + previous_letters = context.get("previous_letters", []) + user_preferences = context.get("user_preferences", {}) + + prompt = f"""テーマ: {theme} + +麻理の手紙構造を生成: +""" + + # Add user history if available + if previous_letters: + prompt += "過去:\n" + for letter in previous_letters[-2:]: # Last 2 letters only + prompt += f"- {letter.get('theme', 'なし')}\n" + prompt += "\n" + + prompt += f"""要求: +- 起承転結の構成 +- 麻理らしい視点 +- 800-1200文字程度 +- 構造のみ(感情表現は後で追加)""" + + return prompt + + async def test_connection(self) -> bool: + """ + Test the connection to Groq API. + + Returns: + True if connection is successful, False otherwise + """ + try: + response = await self.client.chat.completions.create( + model=self.model, + messages=[ + {"role": "user", "content": "こんにちは"} + ], + max_tokens=10 + ) + return True + except Exception as e: + logger.error(f"Groq API connection test failed: {str(e)}") + return False \ No newline at end of file diff --git a/healthcheck.py b/healthcheck.py new file mode 100644 index 0000000000000000000000000000000000000000..2a65e1ff8a153901344209c14109042e1be100f1 --- /dev/null +++ b/healthcheck.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Streamlitアプリケーション用ヘルスチェックスクリプト +Docker環境でのヘルスチェックに使用 +""" + +import sys +import urllib.request +import urllib.error +import json +import time + +def check_streamlit_health(host="localhost", port=8501, timeout=10): + """ + Streamlitアプリケーションのヘルスチェックを実行 + + Args: + host (str): ホスト名 + port (int): ポート番号 + timeout (int): タイムアウト秒数 + + Returns: + bool: ヘルスチェック成功時True + """ + try: + # Streamlitのヘルスチェックエンドポイントを確認 + health_url = f"http://{host}:{port}/_stcore/health" + + request = urllib.request.Request(health_url) + request.add_header('User-Agent', 'HealthCheck/1.0') + + with urllib.request.urlopen(request, timeout=timeout) as response: + if response.status == 200: + print(f"✅ Streamlitアプリケーションは正常に動作しています (ポート: {port})") + return True + else: + print(f"❌ ヘルスチェック失敗: HTTPステータス {response.status}") + return False + + except urllib.error.URLError as e: + print(f"❌ 接続エラー: {e}") + return False + except Exception as e: + print(f"❌ ヘルスチェックエラー: {e}") + return False + +def check_app_responsiveness(host="localhost", port=8501, timeout=10): + """ + アプリケーションの応答性をチェック + + Args: + host (str): ホスト名 + port (int): ポート番号 + timeout (int): タイムアウト秒数 + + Returns: + bool: 応答性チェック成功時True + """ + try: + # メインページへのアクセスを試行 + main_url = f"http://{host}:{port}/" + + request = urllib.request.Request(main_url) + request.add_header('User-Agent', 'HealthCheck/1.0') + + start_time = time.time() + with urllib.request.urlopen(request, timeout=timeout) as response: + response_time = time.time() - start_time + + if response.status == 200: + print(f"✅ アプリケーション応答時間: {response_time:.2f}秒") + return True + else: + print(f"❌ アプリケーション応答エラー: HTTPステータス {response.status}") + return False + + except urllib.error.URLError as e: + print(f"❌ アプリケーション接続エラー: {e}") + return False + except Exception as e: + print(f"❌ アプリケーション応答性チェックエラー: {e}") + return False + +def main(): + """メイン関数""" + print("🔍 Streamlitアプリケーション ヘルスチェック開始...") + + # 基本的なヘルスチェック + health_ok = check_streamlit_health() + + # アプリケーションの応答性チェック + app_ok = check_app_responsiveness() + + # 結果の判定 + if health_ok and app_ok: + print("🎉 全てのヘルスチェックが成功しました!") + sys.exit(0) + else: + print("💥 ヘルスチェックに失敗しました") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/image.png b/image.png new file mode 100644 index 0000000000000000000000000000000000000000..2cd7fa4810fb37ff1a154485387bd6e0fed7587a --- /dev/null +++ b/image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d216494d6d0521bbff87f88a7025a150759365f08db770da76edc63c02660353 +size 209175 diff --git a/jinnjya-hiru.jpg b/jinnjya-hiru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..102d5f4c06d70880a0347939173350cd6f3699de --- /dev/null +++ b/jinnjya-hiru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be83b8b8e6f16d92a1d2701b302b7cef964ffcc678c4c05158761ba361c8d449 +size 3177957 diff --git a/kissa-hiru.jpg b/kissa-hiru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0029426c3676e663791b90113a2073d40a5767ab --- /dev/null +++ b/kissa-hiru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:189ebeccc7fcd3604f76fc7eb0cb253a515bdcf2eb144fca0b5fc4b06a6bfcb2 +size 1056177 diff --git a/letter_app.py b/letter_app.py new file mode 100644 index 0000000000000000000000000000000000000000..62a79a00764bb5c45651fc0a8a7ee46f28f65c2f --- /dev/null +++ b/letter_app.py @@ -0,0 +1,104 @@ +""" +メインアプリケーションモジュール +Main application module +""" + +import asyncio +import sys +from pathlib import Path +from letter_config import Config +from letter_logger import get_app_logger + +logger = get_app_logger() + +class LetterApp: + """非同期手紙生成アプリケーションのメインクラス""" + + def __init__(self): + """アプリケーションを初期化""" + self.config = Config() + self.logger = logger + self._initialized = False + + async def initialize(self) -> bool: + """ + アプリケーションを初期化する + + Returns: + 初期化が成功したかどうか + """ + try: + self.logger.info("アプリケーションを初期化中...") + + # 設定の妥当性をチェック + if not self.config.validate_config(): + self.logger.error("設定の検証に失敗しました") + return False + + # ストレージディレクトリを作成 + await self._setup_storage_directories() + + # ログディレクトリを作成 + await self._setup_log_directories() + + self._initialized = True + self.logger.info("アプリケーションの初期化が完了しました") + return True + + except Exception as e: + self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}") + return False + + async def _setup_storage_directories(self): + """ストレージディレクトリを作成""" + storage_dir = Path(self.config.STORAGE_PATH).parent + backup_dir = Path(self.config.BACKUP_PATH) + + storage_dir.mkdir(parents=True, exist_ok=True) + backup_dir.mkdir(parents=True, exist_ok=True) + + self.logger.info(f"ストレージディレクトリを作成: {storage_dir}") + self.logger.info(f"バックアップディレクトリを作成: {backup_dir}") + + async def _setup_log_directories(self): + """ログディレクトリを作成""" + if not self.config.DEBUG_MODE: + log_dir = Path("/tmp/logs") + log_dir.mkdir(parents=True, exist_ok=True) + self.logger.info(f"ログディレクトリを作成: {log_dir}") + + def is_initialized(self) -> bool: + """アプリケーションが初期化されているかチェック""" + return self._initialized + + def get_config(self) -> Config: + """設定オブジェクトを取得""" + return self.config + +# グローバルアプリケーションインスタンス +app_instance = None + +async def get_app() -> LetterApp: + """アプリケーションインスタンスを取得(シングルトン)""" + global app_instance + + if app_instance is None: + app_instance = LetterApp() + await app_instance.initialize() + + return app_instance + +def run_app(): + """アプリケーションを実行""" + async def main(): + app = await get_app() + if app.is_initialized(): + logger.info("アプリケーションが正常に起動しました") + else: + logger.error("アプリケーションの起動に失敗しました") + sys.exit(1) + + asyncio.run(main()) + +if __name__ == "__main__": + run_app() \ No newline at end of file diff --git a/letter_config.py b/letter_config.py new file mode 100644 index 0000000000000000000000000000000000000000..34f264d6184f3b0383b3b6e8822fb75d37ed0238 --- /dev/null +++ b/letter_config.py @@ -0,0 +1,131 @@ +""" +設定管理モジュール (Together AI API対応版) +Configuration management module for Together AI API +""" + +import os +import logging +from typing import Optional +from dotenv import load_dotenv + +# 環境変数を読み込み +load_dotenv() + +class Config: + """アプリケーション設定クラス""" + + # --- API設定 --- + # Groq APIキー + GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY") + # Together AI APIキー + TOGETHER_API_KEY: Optional[str] = os.getenv("TOGETHER_API_KEY") + + # --- モード設定 --- + # デバッグモード (trueにすると一部ログの出力先がコンソールのみになります) + DEBUG_MODE: bool = os.getenv("DEBUG_MODE", "false").lower() == "true" + + # --- バッチ処理設定 --- + # 手紙を生成する時刻のリスト(深夜2時、3時、4時) + BATCH_SCHEDULE_HOURS: list = [ + int(h.strip()) for h in os.getenv("BATCH_SCHEDULE_HOURS", "2,3,4").split(",") + ] + + # --- 制限設定 --- + # ユーザーごとの1日の最大リクエスト数 + MAX_DAILY_REQUESTS: int = int(os.getenv("MAX_DAILY_REQUESTS", "1")) + + # --- ストレージ設定 --- + # ユーザーデータや手紙を保存するメインのファイルパス + STORAGE_PATH: str = os.getenv("STORAGE_PATH", "tmp/letters.json") + # バックアップデータの保存先ディレクトリ + BACKUP_PATH: str = os.getenv("BACKUP_PATH", "tmp/backup") + + # --- ログ設定 --- + # アプリケーションのログレベル (DEBUG, INFO, WARNING, ERROR, CRITICAL) + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + # ログの出力フォーマット + LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + # --- UI設定 --- + # Streamlitアプリケーションが使用するポート番号 + STREAMLIT_PORT: int = int(os.getenv("STREAMLIT_PORT", "7860")) + + # --- セキュリティ設定 --- + # ユーザーセッションのタイムアウト時間(秒単位) + SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # デフォルト: 1時間 + + # --- 非同期処理設定 --- + # 非同期での手紙生成を有効にするか + ASYNC_LETTER_ENABLED: bool = os.getenv("ASYNC_LETTER_ENABLED", "true").lower() == "true" + # 手紙生成プロセスのタイムアウト時間(秒単位) + GENERATION_TIMEOUT: int = int(os.getenv("GENERATION_TIMEOUT", "300")) # デフォルト: 5分 + # 同時に実行可能な最大手紙生成数 + MAX_CONCURRENT_GENERATIONS: int = int(os.getenv("MAX_CONCURRENT_GENERATIONS", "3")) + + # --- AIモデル設定 --- + # 手紙の論理構造を生成するためのGroqモデル + GROQ_MODEL: str = os.getenv("GROQ_MODEL", "compound-beta") + # 手紙の感情表現を生成するためのTogether AIモデル + TOGETHER_API_MODEL: str = os.getenv("TOGETHER_API_MODEL", "Qwen/Qwen3-235B-A22B-Instruct-2507-tput") + + # --- コンテンツ設定 --- + # ユーザーに提示する選択可能なテーマのリスト + AVAILABLE_THEMES: list = [ + "春の思い出", "夏の夜空", "秋の風景", "冬の静寂", + "友情について", "家族への感謝", "秘めた恋心", "仕事のやりがい", + "最近ハマっている趣味", "忘れられない旅行" + ] + + @classmethod + def validate_config(cls) -> bool: + """ + 設定値の妥当性をチェックし、問題があればエラーログを出力するクラスメソッド + + Returns: + bool: 設定がすべて有効な場合はTrue、そうでなければFalse + """ + errors = [] + + if not cls.GROQ_API_KEY: + errors.append("GROQ_API_KEY is not set. Please add it to your .env file.") + + if not cls.TOGETHER_API_KEY: + errors.append("TOGETHER_API_KEY is not set. Please add it to your .env file.") + + if not all(isinstance(h, int) and h in range(24) for h in cls.BATCH_SCHEDULE_HOURS): + errors.append(f"BATCH_SCHEDULE_HOURS contains invalid values: {cls.BATCH_SCHEDULE_HOURS}") + + if cls.MAX_CONCURRENT_GENERATIONS < 1: + errors.append("MAX_CONCURRENT_GENERATIONS must be at least 1.") + + if cls.GENERATION_TIMEOUT < 60: + errors.append("GENERATION_TIMEOUT must be at least 60 seconds.") + + if cls.MAX_DAILY_REQUESTS < 1: + errors.append("MAX_DAILY_REQUESTS must be at least 1.") + + if errors: + logging.basicConfig(level=logging.ERROR, format=cls.LOG_FORMAT) + for error in errors: + logging.error(f"Configuration validation error: {error}") + return False + + return True + + @classmethod + def get_log_level(cls) -> int: + """ + ログレベルの文字列をloggingモジュールの定数に変換するクラスメソッド + + Returns: + int: loggingモジュールで定義されているログレベル定数 + """ + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL + } + # 指定されたログレベルが存在しない場合はINFOをデフォルトとする + return level_map.get(cls.LOG_LEVEL.upper(), logging.INFO) \ No newline at end of file diff --git a/letter_generator.py b/letter_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..4e4db712e4b485717e109fde7bf42e856d08abf1 --- /dev/null +++ b/letter_generator.py @@ -0,0 +1,231 @@ +""" +Letter generator that combines Groq and Gemini APIs for high-quality letter generation. +""" +import asyncio +from datetime import datetime +from typing import Dict, List, Optional, Any +import logging + +from groq_client import GroqClient +from together_client import TogetherClient + +logger = logging.getLogger(__name__) + +class LetterGenerator: + """Groq + Together AIの組み合わせによる高品質な手紙生成クラス""" + + def __init__(self): + """GroqとTogether AIクライアントを初期化""" + self.groq_client = GroqClient() + self.together_client = TogetherClient() + + async def generate_letter(self, user_id: str, theme: str, user_history: Dict[str, Any]) -> Dict[str, Any]: + """ + テーマとユーザー履歴を基に完成した手紙を生成 + + Args: + user_id: ユーザーID + theme: 手紙のテーマ + user_history: ユーザーの履歴情報 + + Returns: + 生成された手紙の情報を含む辞書 + { + 'content': '手紙の内容', + 'metadata': { + 'theme': 'テーマ', + 'generated_at': '生成日時', + 'groq_model': 'モデル名', + 'together_model': 'モデル名', + 'generation_time': 生成時間(秒), + 'user_id': 'ユーザーID' + } + } + + Raises: + Exception: 手紙生成に失敗した場合 + """ + start_time = datetime.now() + + try: + logger.info(f"ユーザー {user_id} のテーマ '{theme}' で手紙生成開始") + + # ユーザーコンテキストを構築 + context = self._build_context(theme, user_history) + + # ステップ1: Groqで論理構造を生成 + logger.info("Groqで論理構造を生成中...") + structure = await self.groq_client.generate_structure(theme, context) + + # ステップ2: Together AIで感情表現を補完 + logger.info("Together AIで感情表現を補完中...") + enhanced_context = {**context, 'theme': theme} + final_letter = await self.together_client.enhance_emotion(structure, enhanced_context) + + # 生成時間を計算 + generation_time = (datetime.now() - start_time).total_seconds() + + # メタデータを構築 + metadata = { + 'theme': theme, + 'generated_at': datetime.now().isoformat(), + 'groq_model': self.groq_client.model, + 'together_model': self.together_client.model, + 'generation_time': generation_time, + 'user_id': user_id, + 'structure_length': len(structure), + 'final_length': len(final_letter) + } + + logger.info(f"手紙生成完了 (所要時間: {generation_time:.2f}秒)") + + return { + 'content': final_letter, + 'metadata': metadata + } + + except Exception as e: + generation_time = (datetime.now() - start_time).total_seconds() + logger.error(f"手紙生成失敗 (所要時間: {generation_time:.2f}秒): {str(e)}") + raise Exception(f"手紙生成に失敗しました: {str(e)}") + + def _build_context(self, theme: str, user_history: Dict[str, Any]) -> Dict[str, Any]: + """ + ユーザー履歴を考慮したコンテキストを生成 + + Args: + theme: 手紙のテーマ + user_history: ユーザーの履歴情報 + + Returns: + 生成用のコンテキスト辞書 + """ + context = { + 'theme': theme, + 'user_history': user_history, + 'previous_letters': [], + 'interaction_count': 0 + } + + # 過去の手紙情報を抽出 + if 'letters' in user_history: + letters = user_history['letters'] + previous_letters = [] + + for date, letter_data in letters.items(): + if isinstance(letter_data, dict) and letter_data.get('status') == 'completed': + previous_letters.append({ + 'date': date, + 'theme': letter_data.get('theme', ''), + 'content_preview': letter_data.get('content', '')[:100] + '...' if letter_data.get('content') else '' + }) + + # 日付順にソート(新しい順) + previous_letters.sort(key=lambda x: x['date'], reverse=True) + context['previous_letters'] = previous_letters[:5] # 最新5通まで + + # ユーザープロファイル情報を抽出 + if 'profile' in user_history: + profile = user_history['profile'] + context['interaction_count'] = profile.get('total_letters', 0) + + # 季節情報を追加 + current_month = datetime.now().month + if current_month in [12, 1, 2]: + context['season'] = '冬' + elif current_month in [3, 4, 5]: + context['season'] = '春' + elif current_month in [6, 7, 8]: + context['season'] = '夏' + else: + context['season'] = '秋' + + # 時間帯情報を追加 + current_hour = datetime.now().hour + if 5 <= current_hour < 12: + context['time_of_day'] = '朝' + elif 12 <= current_hour < 17: + context['time_of_day'] = '昼' + elif 17 <= current_hour < 21: + context['time_of_day'] = '夕方' + else: + context['time_of_day'] = '夜' + + return context + + async def test_generation_pipeline(self, test_theme: str = "テスト") -> Dict[str, Any]: + """ + 手紙生成パイプラインのテスト + + Args: + test_theme: テスト用のテーマ + + Returns: + テスト結果の辞書 + """ + try: + # テスト用のユーザー履歴 + test_user_history = { + 'profile': { + 'created_at': datetime.now().isoformat(), + 'total_letters': 0 + }, + 'letters': {}, + 'requests': {} + } + + # テスト生成を実行 + result = await self.generate_letter("test_user", test_theme, test_user_history) + + return { + 'success': True, + 'result': result, + 'message': 'テスト生成成功' + } + + except Exception as e: + return { + 'success': False, + 'error': str(e), + 'message': 'テスト生成失敗' + } + + async def check_api_connections(self) -> Dict[str, bool]: + """ + 両方のAPIクライアントの接続状態をチェック + + Returns: + 各APIの接続状態を示す辞書 + """ + try: + groq_status = await self.groq_client.test_connection() + together_status = await self.together_client.test_connection() + + return { + 'groq': groq_status, + 'together': together_status, + 'overall': groq_status and together_status + } + + except Exception as e: + logger.error(f"API接続チェック失敗: {str(e)}") + return { + 'groq': False, + 'together': False, + 'overall': False, + 'error': str(e) + } + + def get_generation_stats(self) -> Dict[str, Any]: + """ + 生成統計情報を取得(将来の拡張用) + + Returns: + 統計情報の辞書 + """ + return { + 'groq_model': self.groq_client.model, + 'together_model': self.together_client.model, + 'max_retries': max(self.groq_client.max_retries, self.together_client.max_retries), + 'available': True + } \ No newline at end of file diff --git a/letter_logger.py b/letter_logger.py new file mode 100644 index 0000000000000000000000000000000000000000..8a2b7be6ac3915fc545addd353c82cb2a8a47a84 --- /dev/null +++ b/letter_logger.py @@ -0,0 +1,75 @@ +""" +ログ設定ユーティリティ +Logging configuration utility +""" + +import logging +import sys +from pathlib import Path +from typing import Optional +from letter_config import Config + +def setup_logger( + name: str, + log_file: Optional[str] = None, + level: Optional[int] = None +) -> logging.Logger: + """ + ロガーを設定する + + Args: + name: ロガー名 + log_file: ログファイルパス(オプション) + level: ログレベル(オプション) + + Returns: + 設定されたロガー + """ + logger = logging.getLogger(name) + + # 既存のハンドラーをクリア + logger.handlers.clear() + + # ログレベル設定 + if level is None: + level = Config.get_log_level() + logger.setLevel(level) + + # フォーマッター作成 + formatter = logging.Formatter(Config.LOG_FORMAT) + + # コンソールハンドラー + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # ファイルハンドラー(指定された場合) + if log_file: + # ログディレクトリを作成 + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + + file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger + +def get_app_logger() -> logging.Logger: + """アプリケーション用のロガーを取得""" + return setup_logger("async_letter_app") + +def get_batch_logger() -> logging.Logger: + """バッチ処理用のロガーを取得""" + log_file = "/tmp/batch.log" if not Config.DEBUG_MODE else None + return setup_logger("batch_processor", log_file) + +def get_api_logger() -> logging.Logger: + """API呼び出し用のロガーを取得""" + log_file = "/tmp/api.log" if not Config.DEBUG_MODE else None + return setup_logger("api_client", log_file) + +def get_storage_logger() -> logging.Logger: + """ストレージ操作用のロガーを取得""" + log_file = "/tmp/storage.log" if not Config.DEBUG_MODE else None + return setup_logger("storage", log_file) \ No newline at end of file diff --git a/letter_manager.py b/letter_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..946cca835780941ec5433a42396950af6559a55a --- /dev/null +++ b/letter_manager.py @@ -0,0 +1,247 @@ +""" +手紙管理マネージャー +Letter management manager +""" + +import uuid +import asyncio +from typing import List, Optional, Dict, Any +from datetime import datetime +from letter_models import Letter, LetterRequest, LetterContent, LetterStatus, UserPreferences +from letter_storage import get_storage +from letter_logger import get_app_logger + +logger = get_app_logger() + +class LetterManager: + """手紙の生成と管理を行うマネージャークラス""" + + def __init__(self): + self.storage = get_storage() + self.logger = logger + + async def create_letter_request( + self, + user_id: str, + message: Optional[str] = None, + preferences: Optional[Dict[str, Any]] = None + ) -> str: + """ + 手紙生成リクエストを作成する + + Args: + user_id: ユーザーID + message: ユーザーからのメッセージ + preferences: ユーザー設定 + + Returns: + 作成された手紙のID + """ + try: + # 一意のIDを生成 + letter_id = str(uuid.uuid4()) + + # リクエストオブジェクトを作成 + request = LetterRequest( + user_id=user_id, + message=message, + preferences=preferences or {} + ) + + # 手紙オブジェクトを作成 + letter = Letter( + id=letter_id, + request=request, + status=LetterStatus.PENDING + ) + + # ストレージに保存 + await self.storage.save_letter(letter.dict()) + + self.logger.info(f"手紙リクエストを作成しました: {letter_id}") + return letter_id + + except Exception as e: + self.logger.error(f"手紙リクエストの作成中にエラーが発生しました: {e}") + raise + + async def get_letter(self, letter_id: str) -> Optional[Letter]: + """ + 手紙データを取得する + + Args: + letter_id: 手紙のID + + Returns: + 手紙データ(見つからない場合はNone) + """ + try: + letter_data = await self.storage.get_letter_by_id(letter_id) + if letter_data: + return Letter(**letter_data) + return None + + except Exception as e: + self.logger.error(f"手紙データの取得中にエラーが発生しました: {e}") + return None + + async def get_user_letters(self, user_id: str) -> List[Letter]: + """ + ユーザーの手紙一覧を取得する + + Args: + user_id: ユーザーID + + Returns: + ユーザーの手紙リスト + """ + try: + all_letters = await self.storage.load_letters() + user_letters = [] + + for letter_data in all_letters: + letter = Letter(**letter_data) + if letter.request.user_id == user_id: + user_letters.append(letter) + + # 作成日時でソート(新しい順) + user_letters.sort(key=lambda x: x.created_at, reverse=True) + + return user_letters + + except Exception as e: + self.logger.error(f"ユーザー手紙一覧の取得中にエラーが発生しました: {e}") + return [] + + async def update_letter_status( + self, + letter_id: str, + status: LetterStatus, + error_message: Optional[str] = None + ) -> bool: + """ + 手紙のステータスを更新する + + Args: + letter_id: 手紙のID + status: 新しいステータス + error_message: エラーメッセージ(エラー時) + + Returns: + 更新が成功したかどうか + """ + try: + letter = await self.get_letter(letter_id) + if not letter: + self.logger.warning(f"更新対象の手紙が見つかりませんでした: {letter_id}") + return False + + letter.update_status(status, error_message) + + # ストレージを更新 + await self._update_letter_in_storage(letter) + + self.logger.info(f"手紙ステータスを更新しました: {letter_id} -> {status}") + return True + + except Exception as e: + self.logger.error(f"手紙ステータスの更新中にエラーが発生しました: {e}") + return False + + async def set_letter_content( + self, + letter_id: str, + content: LetterContent + ) -> bool: + """ + 手紙の内容を設定する + + Args: + letter_id: 手紙のID + content: 手紙の内容 + + Returns: + 設定が成功したかどうか + """ + try: + letter = await self.get_letter(letter_id) + if not letter: + self.logger.warning(f"対象の手紙が見つかりませんでした: {letter_id}") + return False + + letter.set_content(content) + + # ストレージを更新 + await self._update_letter_in_storage(letter) + + self.logger.info(f"手紙の内容を設定しました: {letter_id}") + return True + + except Exception as e: + self.logger.error(f"手紙内容の設定中にエラーが発生しました: {e}") + return False + + async def delete_letter(self, letter_id: str) -> bool: + """ + 手紙を削除する + + Args: + letter_id: 削除する手紙のID + + Returns: + 削除が成功したかどうか + """ + try: + result = await self.storage.delete_letter(letter_id) + if result: + self.logger.info(f"手紙を削除しました: {letter_id}") + return result + + except Exception as e: + self.logger.error(f"手紙の削除中にエラーが発生しました: {e}") + return False + + async def get_pending_letters(self) -> List[Letter]: + """ + 処理待ちの手紙一覧を取得する + + Returns: + 処理待ちの手紙リスト + """ + try: + all_letters = await self.storage.load_letters() + pending_letters = [] + + for letter_data in all_letters: + letter = Letter(**letter_data) + if letter.status == LetterStatus.PENDING: + pending_letters.append(letter) + + # 作成日時でソート(古い順) + pending_letters.sort(key=lambda x: x.created_at) + + return pending_letters + + except Exception as e: + self.logger.error(f"処理待ち手紙一覧の取得中にエラーが発生しました: {e}") + return [] + + async def _update_letter_in_storage(self, letter: Letter) -> None: + """内部用:ストレージ内の手紙データを更新する""" + # 既存データを削除 + await self.storage.delete_letter(letter.id) + + # 新しいデータを保存 + await self.storage.save_letter(letter.dict()) + +# グローバルマネージャーインスタンス +manager_instance = None + +def get_letter_manager() -> LetterManager: + """手紙マネージャーインスタンスを取得(シングルトン)""" + global manager_instance + + if manager_instance is None: + manager_instance = LetterManager() + + return manager_instance \ No newline at end of file diff --git a/letter_models.py b/letter_models.py new file mode 100644 index 0000000000000000000000000000000000000000..58caadc3cce721fc51fd207ff557da5a0b33a4a2 --- /dev/null +++ b/letter_models.py @@ -0,0 +1,324 @@ +""" +手紙生成アプリのデータモデル定義 +LetterRequest、GeneratedLetter、UserProfileのデータクラスと +バリデーション機能を提供します。 +""" + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, Any, Optional, List +import re +import json + + +@dataclass +class LetterRequest: + """手紙リクエストのデータクラス""" + user_id: str + theme: str + requested_at: datetime + generation_hour: int # 2, 3, 4のいずれか + status: str = "pending" + + def to_dict(self) -> Dict[str, Any]: + """辞書形式に変換""" + return { + "user_id": self.user_id, + "theme": self.theme, + "requested_at": self.requested_at.isoformat(), + "generation_hour": self.generation_hour, + "status": self.status + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'LetterRequest': + """辞書からインスタンスを作成""" + return cls( + user_id=data["user_id"], + theme=data["theme"], + requested_at=datetime.fromisoformat(data["requested_at"]), + generation_hour=data["generation_hour"], + status=data.get("status", "pending") + ) + + +@dataclass +class GeneratedLetter: + """生成された手紙のデータクラス""" + user_id: str + theme: str + content: str + generated_at: datetime + metadata: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + """辞書形式に変換""" + return { + "user_id": self.user_id, + "theme": self.theme, + "content": self.content, + "generated_at": self.generated_at.isoformat(), + "metadata": self.metadata + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'GeneratedLetter': + """辞書からインスタンスを作成""" + return cls( + user_id=data["user_id"], + theme=data["theme"], + content=data["content"], + generated_at=datetime.fromisoformat(data["generated_at"]), + metadata=data.get("metadata", {}) + ) + + +@dataclass +class UserProfile: + """ユーザープロファイルのデータクラス""" + user_id: str + created_at: datetime + last_request: Optional[str] = None + total_letters: int = 0 + + def to_dict(self) -> Dict[str, Any]: + """辞書形式に変換""" + return { + "user_id": self.user_id, + "created_at": self.created_at.isoformat(), + "last_request": self.last_request, + "total_letters": self.total_letters + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'UserProfile': + """辞書からインスタンスを作成""" + return cls( + user_id=data["user_id"], + created_at=datetime.fromisoformat(data["created_at"]), + last_request=data.get("last_request"), + total_letters=data.get("total_letters", 0) + ) + + +class ValidationError(Exception): + """バリデーションエラー""" + pass + + +class ThemeValidator: + """テーマのバリデーション機能""" + + MIN_LENGTH = 1 + MAX_LENGTH = 100 + + # 禁止されている文字パターン + FORBIDDEN_PATTERNS = [ + r'<[^>]*>', # HTMLタグ + r'javascript:', # JavaScript + r'data:', # データURL + ] + + @classmethod + def validate(cls, theme: str) -> bool: + """テーマの妥当性を検証""" + if not theme or not isinstance(theme, str): + raise ValidationError("テーマは文字列である必要があります") + + # 長さチェック + theme = theme.strip() + if len(theme) < cls.MIN_LENGTH: + raise ValidationError("テーマは1文字以上入力してください") + + if len(theme) > cls.MAX_LENGTH: + raise ValidationError(f"テーマは{cls.MAX_LENGTH}文字以内で入力してください") + + # 禁止パターンチェック + for pattern in cls.FORBIDDEN_PATTERNS: + if re.search(pattern, theme, re.IGNORECASE): + raise ValidationError("不正な文字が含まれています") + + return True + + @classmethod + def sanitize(cls, theme: str) -> str: + """テーマをサニタイズ""" + if not theme: + return "" + + # 前後の空白を削除 + theme = theme.strip() + + # 改行文字を空白に変換 + theme = re.sub(r'\s+', ' ', theme) + + return theme + + +class GenerationTimeValidator: + """生成時刻のバリデーション機能""" + + VALID_HOURS = [2, 3, 4] # 2時、3時、4時のみ有効 + + @classmethod + def validate(cls, hour: int) -> bool: + """生成時刻の妥当性を検証""" + if not isinstance(hour, int): + raise ValidationError("生成時刻は整数である必要があります") + + if hour not in cls.VALID_HOURS: + valid_hours_str = "、".join(map(str, cls.VALID_HOURS)) + raise ValidationError(f"生成時刻は{valid_hours_str}時のいずれかを選択してください") + + return True + + +class DataValidator: + """データ全般のバリデーション機能""" + + @staticmethod + def validate_user_id(user_id: str) -> bool: + """ユーザーIDの妥当性を検証""" + if not user_id or not isinstance(user_id, str): + raise ValidationError("ユーザーIDは文字列である必要があります") + + # UUIDv4形式のチェック(簡易版) + 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}$' + if not re.match(uuid_pattern, user_id, re.IGNORECASE): + raise ValidationError("ユーザーIDの形式が正しくありません") + + return True + + @staticmethod + def validate_letter_request(request: LetterRequest) -> bool: + """手紙リクエストの妥当性を検証""" + DataValidator.validate_user_id(request.user_id) + ThemeValidator.validate(request.theme) + GenerationTimeValidator.validate(request.generation_hour) + + # ステータスの検証 + valid_statuses = ["pending", "processing", "completed", "failed"] + if request.status not in valid_statuses: + raise ValidationError(f"ステータスは{valid_statuses}のいずれかである必要があります") + + return True + + @staticmethod + def validate_generated_letter(letter: GeneratedLetter) -> bool: + """生成された手紙の妥当性を検証""" + DataValidator.validate_user_id(letter.user_id) + ThemeValidator.validate(letter.theme) + + # 手紙内容の検証 + if not letter.content or not isinstance(letter.content, str): + raise ValidationError("手紙の内容は文字列である必要があります") + + if len(letter.content.strip()) < 10: + raise ValidationError("手紙の内容が短すぎます") + + return True + + @staticmethod + def validate_user_profile(profile: UserProfile) -> bool: + """ユーザープロファイルの妥当性を検証""" + DataValidator.validate_user_id(profile.user_id) + + # 手紙数の検証 + if not isinstance(profile.total_letters, int) or profile.total_letters < 0: + raise ValidationError("手紙数は0以上の整数である必要があります") + + return True + + +# テスト用のサンプルデータ作成関数 +def create_sample_data(): + """テスト用のサンプルデータを作成""" + import uuid + + user_id = str(uuid.uuid4()) + now = datetime.now() + + # サンプルリクエスト + request = LetterRequest( + user_id=user_id, + theme="春の思い出", + requested_at=now, + generation_hour=2 + ) + + # サンプル手紙 + letter = GeneratedLetter( + user_id=user_id, + theme="春の思い出", + content="桜の花びらが舞い散る季節になりました。あなたとの思い出が蘇ります...", + generated_at=now, + metadata={ + "groq_model": "compound-beta", + "Together_model": "Qwen/Qwen3-235B-A22B-Instruct-2507-tput", + "generation_time": 12.5 + } + ) + + # サンプルプロファイル + profile = UserProfile( + user_id=user_id, + created_at=now, + last_request="2024-01-20", + total_letters=1 + ) + + return request, letter, profile + + +if __name__ == "__main__": + # テスト実行 + try: + request, letter, profile = create_sample_data() + + print("=== バリデーションテスト ===") + + # リクエストのバリデーション + DataValidator.validate_letter_request(request) + print("✓ LetterRequestのバリデーション成功") + + # 手紙のバリデーション + DataValidator.validate_generated_letter(letter) + print("✓ GeneratedLetterのバリデーション成功") + + # プロファイルのバリデーション + DataValidator.validate_user_profile(profile) + print("✓ UserProfileのバリデーション成功") + + print("\n=== シリアライゼーションテスト ===") + + # 辞書変換テスト + request_dict = request.to_dict() + request_restored = LetterRequest.from_dict(request_dict) + print("✓ LetterRequestのシリアライゼーション成功") + + letter_dict = letter.to_dict() + letter_restored = GeneratedLetter.from_dict(letter_dict) + print("✓ GeneratedLetterのシリアライゼーション成功") + + profile_dict = profile.to_dict() + profile_restored = UserProfile.from_dict(profile_dict) + print("✓ UserProfileのシリアライゼーション成功") + + print("\n=== エラーケーステスト ===") + + # 不正なテーマのテスト + try: + ThemeValidator.validate("") + except ValidationError as e: + print(f"✓ 空のテーマエラー: {e}") + + # 不正な生成時刻のテスト + try: + GenerationTimeValidator.validate(5) + except ValidationError as e: + print(f"✓ 不正な生成時刻エラー: {e}") + + print("\n全てのテストが完了しました!") + + except Exception as e: + print(f"エラーが発生しました: {e}") \ No newline at end of file diff --git a/letter_request_manager.py b/letter_request_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..74b60f278df060702235dffafd2ae60240fefa6a --- /dev/null +++ b/letter_request_manager.py @@ -0,0 +1,462 @@ +""" +リクエスト管理クラス +テーマと生成時刻を含むリクエスト送信機能と +時刻別の未処理リクエスト取得機能を提供します。 +""" + +import asyncio +import os +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional, Tuple +import logging +import uuid + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class RequestError(Exception): + """リクエスト関連のエラー""" + pass + + +class RequestManager: + """リクエスト管理クラス""" + + def __init__(self, storage_manager, rate_limiter): + self.storage = storage_manager + self.rate_limiter = rate_limiter + + # 設定値 + self.valid_generation_hours = [2, 3, 4] # 2時、3時、4時 + self.max_theme_length = int(os.getenv("MAX_THEME_LENGTH", "200")) + self.min_theme_length = int(os.getenv("MIN_THEME_LENGTH", "1")) + + logger.info(f"RequestManager初期化完了 - 有効な生成時刻: {self.valid_generation_hours}") + + async def submit_request(self, user_id: str, theme: str, generation_hour: int, affection: int = None) -> Tuple[bool, str]: + """ + リクエストを送信する + + Args: + user_id: ユーザーID + theme: 手紙のテーマ + generation_hour: 生成時刻(2, 3, 4のいずれか) + affection: 現在の好感度(オプション) + + Returns: + Tuple[bool, str]: (成功フラグ, メッセージ) + """ + try: + # 入力バリデーション + if not self.validate_theme(theme): + return False, f"テーマは{self.min_theme_length}文字以上{self.max_theme_length}文字以下で入力してください" + + if not self.validate_generation_hour(generation_hour): + return False, f"生成時刻は{self.valid_generation_hours}のいずれかを選択してください" + + # レート制限チェック + allowed, limit_message = await self.rate_limiter.is_request_allowed(user_id) + if not allowed: + return False, limit_message + + # 既存のリクエストチェック(同日の重複防止) + today = datetime.now().strftime("%Y-%m-%d") + existing_request = await self._get_user_request_for_date(user_id, today) + + if existing_request: + return False, "本日は既にリクエストを送信済みです。1日1回までリクエスト可能です。" + + # リクエストデータの作成 + request_data = { + "theme": theme.strip(), + "status": "pending", + "requested_at": datetime.now().isoformat(), + "generation_hour": generation_hour, + "request_id": str(uuid.uuid4()), + "affection": affection # 好感度情報を追加 + } + + # ユーザーデータの取得と更新 + user_data = await self.storage.get_user_data(user_id) + user_data["requests"][today] = request_data + + # ストレージに保存 + await self.storage.update_user_data(user_id, user_data) + + # レート制限の記録 + await self.rate_limiter.record_request(user_id) + + logger.info(f"リクエスト送信成功 - ユーザー: {user_id}, テーマ: {theme[:50]}..., 生成時刻: {generation_hour}時") + + return True, f"リクエストを受け付けました。{generation_hour}時頃に手紙を生成します。" + + except Exception as e: + logger.error(f"リクエスト送信エラー: {e}") + return False, f"リクエストの送信中にエラーが発生しました: {str(e)}" + + async def get_pending_requests_by_hour(self, hour: int) -> List[Dict[str, Any]]: + """ + 指定時刻の未処理リクエストを取得する + + Args: + hour: 生成時刻(2, 3, 4のいずれか) + + Returns: + List[Dict]: 未処理リクエストのリスト + """ + try: + if hour not in self.valid_generation_hours: + logger.warning(f"無効な生成時刻が指定されました: {hour}") + return [] + + all_users = await self.storage.get_all_users() + pending_requests = [] + today = datetime.now().strftime("%Y-%m-%d") + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 今日のリクエストをチェック + if today in user_data["requests"]: + request = user_data["requests"][today] + + # 指定時刻かつ未処理のリクエストを抽出 + if (request.get("generation_hour") == hour and + request.get("status") == "pending"): + + pending_requests.append({ + "user_id": user_id, + "theme": request["theme"], + "requested_at": request["requested_at"], + "generation_hour": request["generation_hour"], + "request_id": request.get("request_id", ""), + "date": today + }) + + logger.info(f"{hour}時の未処理リクエスト数: {len(pending_requests)}") + return pending_requests + + except Exception as e: + logger.error(f"未処理リクエスト取得エラー: {e}") + return [] + + async def mark_request_processed(self, user_id: str, date: str, status: str = "completed") -> bool: + """ + リクエストを処理済みにマークする + + Args: + user_id: ユーザーID + date: 日付(YYYY-MM-DD形式) + status: 新しいステータス(completed, failed等) + + Returns: + bool: 成功フラグ + """ + try: + user_data = await self.storage.get_user_data(user_id) + + if date not in user_data["requests"]: + logger.warning(f"指定された日付のリクエストが見つかりません - ユーザー: {user_id}, 日付: {date}") + return False + + # ステータスを更新 + user_data["requests"][date]["status"] = status + user_data["requests"][date]["processed_at"] = datetime.now().isoformat() + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"リクエストを{status}にマークしました - ユーザー: {user_id}, 日付: {date}") + return True + + except Exception as e: + logger.error(f"リクエスト処理マークエラー: {e}") + return False + + async def mark_request_failed(self, user_id: str, date: str, error_message: str) -> bool: + """ + リクエストを失敗にマークする + + Args: + user_id: ユーザーID + date: 日付(YYYY-MM-DD形式) + error_message: エラーメッセージ + + Returns: + bool: 成功フラグ + """ + try: + user_data = await self.storage.get_user_data(user_id) + + if date not in user_data["requests"]: + logger.warning(f"指定された日付のリクエストが見つかりません - ユーザー: {user_id}, 日付: {date}") + return False + + # ステータスとエラー情報を更新 + user_data["requests"][date]["status"] = "failed" + user_data["requests"][date]["processed_at"] = datetime.now().isoformat() + user_data["requests"][date]["error_message"] = error_message + + await self.storage.update_user_data(user_id, user_data) + + logger.error(f"リクエストを失敗にマークしました - ユーザー: {user_id}, 日付: {date}, エラー: {error_message}") + return True + + except Exception as e: + logger.error(f"リクエスト失敗マークエラー: {e}") + return False + + def validate_theme(self, theme: str) -> bool: + """ + テーマのバリデーション + + Args: + theme: テーマ文字列 + + Returns: + bool: バリデーション結果 + """ + if not theme or not isinstance(theme, str): + return False + + theme = theme.strip() + + # 長さチェック + if len(theme) < self.min_theme_length or len(theme) > self.max_theme_length: + return False + + # 不正な文字のチェック(基本的な制御文字のみ) + if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in theme): + return False + + return True + + def validate_generation_hour(self, hour: int) -> bool: + """ + 生成時刻のバリデーション + + Args: + hour: 生成時刻 + + Returns: + bool: バリデーション結果 + """ + return isinstance(hour, int) and hour in self.valid_generation_hours + + async def get_user_request_status(self, user_id: str, date: Optional[str] = None) -> Dict[str, Any]: + """ + ユーザーのリクエスト状況を取得する + + Args: + user_id: ユーザーID + date: 日付(指定しない場合は今日) + + Returns: + Dict: リクエスト状況 + """ + try: + if date is None: + date = datetime.now().strftime("%Y-%m-%d") + + user_data = await self.storage.get_user_data(user_id) + + if date not in user_data["requests"]: + return { + "has_request": False, + "date": date, + "can_request": True + } + + request = user_data["requests"][date] + + return { + "has_request": True, + "date": date, + "theme": request["theme"], + "status": request["status"], + "generation_hour": request["generation_hour"], + "requested_at": request["requested_at"], + "processed_at": request.get("processed_at"), + "error_message": request.get("error_message"), + "can_request": False # 既にリクエスト済み + } + + except Exception as e: + logger.error(f"リクエスト状況取得エラー: {e}") + return {"error": str(e)} + + async def _get_user_request_for_date(self, user_id: str, date: str) -> Optional[Dict[str, Any]]: + """ + 指定日のユーザーリクエストを取得する(内部使用) + + Args: + user_id: ユーザーID + date: 日付(YYYY-MM-DD形式) + + Returns: + Optional[Dict]: リクエストデータ(存在しない場合はNone) + """ + try: + user_data = await self.storage.get_user_data(user_id) + return user_data["requests"].get(date) + except Exception as e: + logger.error(f"ユーザーリクエスト取得エラー: {e}") + return None + + async def get_all_pending_requests(self) -> Dict[int, List[Dict[str, Any]]]: + """ + 全ての未処理リクエストを時刻別に取得する + + Returns: + Dict[int, List]: 時刻別の未処理リクエスト + """ + try: + all_pending = {} + + for hour in self.valid_generation_hours: + pending_requests = await self.get_pending_requests_by_hour(hour) + all_pending[hour] = pending_requests + + return all_pending + + except Exception as e: + logger.error(f"全未処理リクエスト取得エラー: {e}") + return {} + + async def cleanup_old_requests(self, days: int = 30) -> int: + """ + 古いリクエストデータを削除する + + Args: + days: 保持日数 + + Returns: + int: 削除されたリクエスト数 + """ + try: + cutoff_date = datetime.now() - timedelta(days=days) + cutoff_str = cutoff_date.strftime("%Y-%m-%d") + + all_users = await self.storage.get_all_users() + deleted_count = 0 + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 古いリクエストを削除 + requests_to_delete = [] + for date_str in user_data["requests"]: + if date_str < cutoff_str: + requests_to_delete.append(date_str) + + for date_str in requests_to_delete: + del user_data["requests"][date_str] + deleted_count += 1 + + if requests_to_delete: + await self.storage.update_user_data(user_id, user_data) + + if deleted_count > 0: + logger.info(f"{deleted_count}件の古いリクエストを削除しました") + + return deleted_count + + except Exception as e: + logger.error(f"古いリクエスト削除エラー: {e}") + return 0 + + async def get_request_statistics(self) -> Dict[str, Any]: + """ + リクエストの統計情報を取得する + + Returns: + Dict: 統計情報 + """ + try: + all_users = await self.storage.get_all_users() + today = datetime.now().strftime("%Y-%m-%d") + + stats = { + "total_users": len(all_users), + "today_requests": 0, + "pending_requests": 0, + "completed_requests": 0, + "failed_requests": 0, + "requests_by_hour": {hour: 0 for hour in self.valid_generation_hours}, + "date": today + } + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 今日のリクエストをカウント + if today in user_data["requests"]: + request = user_data["requests"][today] + stats["today_requests"] += 1 + + # ステータス別カウント + status = request.get("status", "unknown") + if status == "pending": + stats["pending_requests"] += 1 + elif status == "completed": + stats["completed_requests"] += 1 + elif status == "failed": + stats["failed_requests"] += 1 + + # 時刻別カウント + hour = request.get("generation_hour") + if hour in stats["requests_by_hour"]: + stats["requests_by_hour"][hour] += 1 + + return stats + + except Exception as e: + logger.error(f"統計情報取得エラー: {e}") + return {"error": str(e)} + + +# テスト用の関数 +async def test_request_manager(): + """RequestManagerのテスト""" + import tempfile + from async_storage_manager import AsyncStorageManager + from async_rate_limiter import AsyncRateLimitManager + + # 一時ディレクトリでテスト + with tempfile.TemporaryDirectory() as temp_dir: + test_file = os.path.join(temp_dir, "test_letters.json") + storage = AsyncStorageManager(test_file) + rate_limiter = AsyncRateLimitManager(storage) + request_manager = RequestManager(storage, rate_limiter) + + print("=== RequestManagerテスト開始 ===") + + user_id = str(uuid.uuid4()) + + # リクエスト送信テスト + success, message = await request_manager.submit_request(user_id, "春の思い出", 2) + print(f"✓ リクエスト送信テスト: {success} - {message}") + + # 未処理リクエスト取得テスト + pending = await request_manager.get_pending_requests_by_hour(2) + print(f"✓ 未処理リクエスト取得テスト: {len(pending)}件") + + # リクエスト状況確認テスト + status = await request_manager.get_user_request_status(user_id) + print(f"✓ リクエスト状況確認テスト: {status}") + + # リクエスト処理マークテスト + today = datetime.now().strftime("%Y-%m-%d") + marked = await request_manager.mark_request_processed(user_id, today) + print(f"✓ リクエスト処理マークテスト: {marked}") + + # 統計情報取得テスト + stats = await request_manager.get_request_statistics() + print(f"✓ 統計情報取得テスト: {stats}") + + print("=== 全てのテストが完了しました! ===") + + +if __name__ == "__main__": + asyncio.run(test_request_manager()) \ No newline at end of file diff --git a/letter_storage.py b/letter_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..af16480fde7652406e4eff99972aa4689b8e6f92 --- /dev/null +++ b/letter_storage.py @@ -0,0 +1,190 @@ +""" +ストレージ管理モジュール +Storage management module +""" + +import json +import asyncio +import aiofiles +from pathlib import Path +from typing import Dict, List, Optional, Any +from datetime import datetime +from letter_config import Config +from letter_logger import get_storage_logger + +logger = get_storage_logger() + +class LetterStorage: + """手紙データのストレージ管理クラス""" + + def __init__(self): + self.config = Config() + self.storage_path = Path(self.config.STORAGE_PATH) + self.backup_path = Path(self.config.BACKUP_PATH) + self.logger = logger + + async def save_letter(self, letter_data: Dict[str, Any]) -> bool: + """ + 手紙データを保存する + + Args: + letter_data: 保存する手紙データ + + Returns: + 保存が成功したかどうか + """ + try: + # タイムスタンプを追加 + letter_data['saved_at'] = datetime.now().isoformat() + + # 既存データを読み込み + existing_data = await self._load_data() + + # 新しいデータを追加 + if 'letters' not in existing_data: + existing_data['letters'] = [] + + existing_data['letters'].append(letter_data) + + # データを保存 + await self._save_data(existing_data) + + self.logger.info(f"手紙データを保存しました: {letter_data.get('id', 'unknown')}") + return True + + except Exception as e: + self.logger.error(f"手紙データの保存中にエラーが発生しました: {e}") + return False + + async def load_letters(self) -> List[Dict[str, Any]]: + """ + 保存された手紙データを読み込む + + Returns: + 手紙データのリスト + """ + try: + data = await self._load_data() + return data.get('letters', []) + + except Exception as e: + self.logger.error(f"手紙データの読み込み中にエラーが発生しました: {e}") + return [] + + async def get_letter_by_id(self, letter_id: str) -> Optional[Dict[str, Any]]: + """ + IDで手紙データを取得する + + Args: + letter_id: 手紙のID + + Returns: + 手紙データ(見つからない場合はNone) + """ + try: + letters = await self.load_letters() + for letter in letters: + if letter.get('id') == letter_id: + return letter + return None + + except Exception as e: + self.logger.error(f"手紙データの取得中にエラーが発生しました: {e}") + return None + + async def delete_letter(self, letter_id: str) -> bool: + """ + 手紙データを削除する + + Args: + letter_id: 削除する手紙のID + + Returns: + 削除が成功したかどうか + """ + try: + data = await self._load_data() + letters = data.get('letters', []) + + # 指定されたIDの手紙を削除 + original_count = len(letters) + letters = [letter for letter in letters if letter.get('id') != letter_id] + + if len(letters) < original_count: + data['letters'] = letters + await self._save_data(data) + self.logger.info(f"手紙データを削除しました: {letter_id}") + return True + else: + self.logger.warning(f"削除対象の手紙が見つかりませんでした: {letter_id}") + return False + + except Exception as e: + self.logger.error(f"手紙データの削除中にエラーが発生しました: {e}") + return False + + async def backup_data(self) -> bool: + """ + データをバックアップする + + Returns: + バックアップが成功したかどうか + """ + try: + if not self.storage_path.exists(): + self.logger.warning("バックアップ対象のファイルが存在しません") + return False + + # バックアップディレクトリを作成 + self.backup_path.mkdir(parents=True, exist_ok=True) + + # タイムスタンプ付きのバックアップファイル名 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = self.backup_path / f"letters_backup_{timestamp}.json" + + # ファイルをコピー + async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as src: + content = await src.read() + async with aiofiles.open(backup_file, 'w', encoding='utf-8') as dst: + await dst.write(content) + + self.logger.info(f"データをバックアップしました: {backup_file}") + return True + + except Exception as e: + self.logger.error(f"データのバックアップ中にエラーが発生しました: {e}") + return False + + async def _load_data(self) -> Dict[str, Any]: + """内部用:データファイルを読み込む""" + try: + if not self.storage_path.exists(): + return {} + + async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f: + content = await f.read() + return json.loads(content) if content.strip() else {} + + except Exception as e: + self.logger.error(f"データファイルの読み込み中にエラーが発生しました: {e}") + return {} + + async def _save_data(self, data: Dict[str, Any]) -> None: + """内部用:データファイルに保存する""" + # ディレクトリを作成 + self.storage_path.parent.mkdir(parents=True, exist_ok=True) + + async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f: + await f.write(json.dumps(data, ensure_ascii=False, indent=2)) + +# グローバルストレージインスタンス +storage_instance = None + +def get_storage() -> LetterStorage: + """ストレージインスタンスを取得(シングルトン)""" + global storage_instance + + if storage_instance is None: + storage_instance = LetterStorage() + + return storage_instance \ No newline at end of file diff --git a/letter_user_manager.py b/letter_user_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..0072aa7d87e50488592578620b0cabe0ed374d20 --- /dev/null +++ b/letter_user_manager.py @@ -0,0 +1,666 @@ +""" +ユーザー管理クラス +ユーザープロファイル管理機能と +ユーザー履歴の更新・取得機能を提供します。 +""" + +import asyncio +import os +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional, Tuple +import logging +import uuid +import hashlib +import secrets + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class UserError(Exception): + """ユーザー関連のエラー""" + pass + + +class UserManager: + """ユーザー管理クラス""" + + def __init__(self, storage_manager): + self.storage = storage_manager + + # 設定値 + self.session_timeout_hours = int(os.getenv("SESSION_TIMEOUT_HOURS", "24")) + self.max_history_entries = int(os.getenv("MAX_HISTORY_ENTRIES", "100")) + self.user_data_retention_days = int(os.getenv("USER_DATA_RETENTION_DAYS", "90")) + + logger.info(f"UserManager初期化完了 - セッションタイムアウト: {self.session_timeout_hours}時間") + + def generate_user_id(self) -> str: + """ + 新しいユーザーIDを生成する + + Returns: + str: 生成されたユーザーID(UUID4形式) + """ + return str(uuid.uuid4()) + + def generate_session_id(self) -> str: + """ + 新しいセッションIDを生成する + + Returns: + str: 生成されたセッションID + """ + return secrets.token_urlsafe(32) + + async def get_user_profile(self, user_id: str) -> Dict[str, Any]: + """ + ユーザープロファイルを取得する + + Args: + user_id: ユーザーID + + Returns: + Dict: ユーザープロファイル + """ + try: + user_data = await self.storage.get_user_data(user_id) + profile = user_data["profile"].copy() + + # 追加の統計情報を計算 + profile["total_requests"] = len(user_data["requests"]) + profile["completed_letters"] = len([ + letter for letter in user_data["letters"].values() + if letter.get("status") == "completed" + ]) + profile["pending_requests"] = len([ + request for request in user_data["requests"].values() + if request.get("status") == "pending" + ]) + + # 最後のアクティビティ時刻を計算 + last_activity = self._calculate_last_activity(user_data) + if last_activity: + profile["last_activity"] = last_activity + + return profile + + except Exception as e: + logger.error(f"ユーザープロファイル取得エラー: {e}") + raise UserError(f"ユーザープロファイルの取得に失敗しました: {e}") + + async def update_user_profile(self, user_id: str, profile_updates: Dict[str, Any]) -> bool: + """ + ユーザープロファイルを更新する + + Args: + user_id: ユーザーID + profile_updates: 更新するプロファイル情報 + + Returns: + bool: 更新成功フラグ + """ + try: + user_data = await self.storage.get_user_data(user_id) + + # 更新可能なフィールドのみを許可 + allowed_fields = { + "display_name", "preferences", "timezone", "language", + "notification_settings", "theme_preferences" + } + + for key, value in profile_updates.items(): + if key in allowed_fields: + user_data["profile"][key] = value + + # 更新時刻を記録 + user_data["profile"]["updated_at"] = datetime.now().isoformat() + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"ユーザープロファイルを更新しました: {user_id}") + return True + + except Exception as e: + logger.error(f"ユーザープロファイル更新エラー: {e}") + return False + + async def update_user_history(self, user_id: str, interaction: Dict[str, Any]) -> bool: + """ + ユーザー履歴を更新する + + Args: + user_id: ユーザーID + interaction: インタラクション情報 + + Returns: + bool: 更新成功フラグ + """ + try: + user_data = await self.storage.get_user_data(user_id) + + # 履歴エントリの作成 + history_entry = { + "timestamp": datetime.now().isoformat(), + "type": interaction.get("type", "unknown"), + "action": interaction.get("action", ""), + "details": interaction.get("details", {}), + "session_id": interaction.get("session_id", ""), + "entry_id": str(uuid.uuid4()) + } + + # 履歴配列の初期化(存在しない場合) + if "history" not in user_data: + user_data["history"] = [] + + # 履歴エントリを追加 + user_data["history"].append(history_entry) + + # 履歴の上限チェックと古いエントリの削除 + if len(user_data["history"]) > self.max_history_entries: + # 古いエントリから削除 + user_data["history"] = user_data["history"][-self.max_history_entries:] + + # プロファイルの統計情報を更新 + await self._update_profile_stats(user_data, interaction) + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"ユーザー履歴を更新しました: {user_id} - {interaction.get('type', 'unknown')}") + return True + + except Exception as e: + logger.error(f"ユーザー履歴更新エラー: {e}") + return False + + async def get_user_letter_history(self, user_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]: + """ + ユーザーの手紙履歴を取得する + + Args: + user_id: ユーザーID + limit: 取得件数の上限 + + Returns: + List[Dict]: 手紙履歴のリスト + """ + try: + user_data = await self.storage.get_user_data(user_id) + letters = user_data["letters"] + + # 手紙データを日付順(新しい順)でソート + sorted_letters = [] + for date, letter_data in letters.items(): + letter_info = { + "date": date, + "theme": letter_data.get("theme", ""), + "status": letter_data.get("status", "unknown"), + "generated_at": letter_data.get("generated_at"), + "content_length": len(letter_data.get("content", "")), + "metadata": letter_data.get("metadata", {}) + } + sorted_letters.append(letter_info) + + # 日付順でソート(新しい順) + sorted_letters.sort(key=lambda x: x["date"], reverse=True) + + # 上限が指定されている場合は制限 + if limit: + sorted_letters = sorted_letters[:limit] + + return sorted_letters + + except Exception as e: + logger.error(f"手紙履歴取得エラー: {e}") + return [] + + async def get_user_interaction_history(self, user_id: str, + interaction_type: Optional[str] = None, + limit: Optional[int] = None) -> List[Dict[str, Any]]: + """ + ユーザーのインタラクション履歴を取得する + + Args: + user_id: ユーザーID + interaction_type: フィルタするインタラクションタイプ + limit: 取得件数の上限 + + Returns: + List[Dict]: インタラクション履歴のリスト + """ + try: + user_data = await self.storage.get_user_data(user_id) + history = user_data.get("history", []) + + # タイプでフィルタ + if interaction_type: + history = [entry for entry in history if entry.get("type") == interaction_type] + + # 時刻順でソート(新しい順) + history.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + + # 上限が指定されている場合は制限 + if limit: + history = history[:limit] + + return history + + except Exception as e: + logger.error(f"インタラクション履歴取得エラー: {e}") + return [] + + async def create_user_session(self, user_id: str, session_info: Dict[str, Any]) -> str: + """ + ユーザーセッションを作成する + + Args: + user_id: ユーザーID + session_info: セッション情報 + + Returns: + str: セッションID + """ + try: + session_id = self.generate_session_id() + user_data = await self.storage.get_user_data(user_id) + + # セッション情報の作成 + session_data = { + "session_id": session_id, + "created_at": datetime.now().isoformat(), + "expires_at": (datetime.now() + timedelta(hours=self.session_timeout_hours)).isoformat(), + "ip_address": session_info.get("ip_address", ""), + "user_agent": session_info.get("user_agent", ""), + "is_active": True + } + + # セッション配列の初期化(存在しない場合) + if "sessions" not in user_data: + user_data["sessions"] = [] + + # 古いセッションを無効化 + await self._cleanup_expired_sessions(user_data) + + # 新しいセッションを追加 + user_data["sessions"].append(session_data) + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"ユーザーセッションを作成しました: {user_id} - {session_id}") + return session_id + + except Exception as e: + logger.error(f"セッション作成エラー: {e}") + raise UserError(f"セッションの作成に失敗しました: {e}") + + async def validate_user_session(self, user_id: str, session_id: str) -> bool: + """ + ユーザーセッションを検証する + + Args: + user_id: ユーザーID + session_id: セッションID + + Returns: + bool: セッション有効フラグ + """ + try: + user_data = await self.storage.get_user_data(user_id) + sessions = user_data.get("sessions", []) + + for session in sessions: + if (session.get("session_id") == session_id and + session.get("is_active", False)): + + # 有効期限チェック + expires_at = datetime.fromisoformat(session["expires_at"]) + if datetime.now() < expires_at: + return True + else: + # 期限切れセッションを無効化 + session["is_active"] = False + await self.storage.update_user_data(user_id, user_data) + + return False + + except Exception as e: + logger.error(f"セッション検証エラー: {e}") + return False + + async def invalidate_user_session(self, user_id: str, session_id: str) -> bool: + """ + ユーザーセッションを無効化する + + Args: + user_id: ユーザーID + session_id: セッションID + + Returns: + bool: 無効化成功フラグ + """ + try: + user_data = await self.storage.get_user_data(user_id) + sessions = user_data.get("sessions", []) + + for session in sessions: + if session.get("session_id") == session_id: + session["is_active"] = False + session["invalidated_at"] = datetime.now().isoformat() + + await self.storage.update_user_data(user_id, user_data) + + logger.info(f"セッションを無効化しました: {user_id} - {session_id}") + return True + + return False + + except Exception as e: + logger.error(f"セッション無効化エラー: {e}") + return False + + async def get_user_preferences(self, user_id: str) -> Dict[str, Any]: + """ + ユーザーの設定を取得する + + Args: + user_id: ユーザーID + + Returns: + Dict: ユーザー設定 + """ + try: + profile = await self.get_user_profile(user_id) + + # デフォルト設定 + default_preferences = { + "theme": "light", + "language": "ja", + "timezone": "Asia/Tokyo", + "notification_enabled": True, + "generation_time_preference": 2, # デフォルトは2時 + "theme_suggestions": True, + "history_retention": True + } + + # ユーザー設定をマージ + preferences = default_preferences.copy() + if "preferences" in profile: + preferences.update(profile["preferences"]) + + return preferences + + except Exception as e: + logger.error(f"ユーザー設定取得エラー: {e}") + return {} + + async def update_user_preferences(self, user_id: str, preferences: Dict[str, Any]) -> bool: + """ + ユーザーの設定を更新する + + Args: + user_id: ユーザーID + preferences: 更新する設定 + + Returns: + bool: 更新成功フラグ + """ + try: + current_preferences = await self.get_user_preferences(user_id) + current_preferences.update(preferences) + + return await self.update_user_profile(user_id, {"preferences": current_preferences}) + + except Exception as e: + logger.error(f"ユーザー設定更新エラー: {e}") + return False + + async def _update_profile_stats(self, user_data: Dict[str, Any], interaction: Dict[str, Any]) -> None: + """ + プロファイルの統計情報を更新する(内部使用) + + Args: + user_data: ユーザーデータ + interaction: インタラクション情報 + """ + try: + profile = user_data["profile"] + interaction_type = interaction.get("type", "") + + # インタラクションタイプ別の統計更新 + if interaction_type == "letter_request": + profile["total_letters"] = profile.get("total_letters", 0) + 1 + elif interaction_type == "letter_generated": + # 生成完了時の統計更新は別途処理 + pass + elif interaction_type == "app_access": + profile["last_access"] = datetime.now().isoformat() + + # 最終アクティビティ時刻を更新 + profile["last_activity"] = datetime.now().isoformat() + + except Exception as e: + logger.error(f"プロファイル統計更新エラー: {e}") + + def _calculate_last_activity(self, user_data: Dict[str, Any]) -> Optional[str]: + """ + 最後のアクティビティ時刻を計算する(内部使用) + + Args: + user_data: ユーザーデータ + + Returns: + Optional[str]: 最後のアクティビティ時刻 + """ + try: + timestamps = [] + + # プロファイルの最終アクセス時刻 + if "last_access" in user_data["profile"]: + timestamps.append(user_data["profile"]["last_access"]) + + # 履歴の最新エントリ + history = user_data.get("history", []) + if history: + latest_history = max(history, key=lambda x: x.get("timestamp", "")) + timestamps.append(latest_history["timestamp"]) + + # リクエストの最新エントリ + requests = user_data.get("requests", {}) + if requests: + latest_request = max(requests.values(), key=lambda x: x.get("requested_at", "")) + timestamps.append(latest_request["requested_at"]) + + if timestamps: + return max(timestamps) + + return None + + except Exception as e: + logger.error(f"最終アクティビティ計算エラー: {e}") + return None + + async def _cleanup_expired_sessions(self, user_data: Dict[str, Any]) -> None: + """ + 期限切れセッションをクリーンアップする(内部使用) + + Args: + user_data: ユーザーデータ + """ + try: + sessions = user_data.get("sessions", []) + current_time = datetime.now() + + for session in sessions: + if session.get("is_active", False): + expires_at = datetime.fromisoformat(session["expires_at"]) + if current_time >= expires_at: + session["is_active"] = False + session["expired_at"] = current_time.isoformat() + + except Exception as e: + logger.error(f"セッションクリーンアップエラー: {e}") + + async def cleanup_old_user_data(self, days: int = None) -> int: + """ + 古いユーザーデータを削除する + + Args: + days: 保持日数(指定しない場合は設定値を使用) + + Returns: + int: 削除されたエントリ数 + """ + try: + if days is None: + days = self.user_data_retention_days + + cutoff_date = datetime.now() - timedelta(days=days) + cutoff_str = cutoff_date.strftime("%Y-%m-%d") + + all_users = await self.storage.get_all_users() + deleted_count = 0 + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 古い履歴エントリを削除 + history = user_data.get("history", []) + original_count = len(history) + + user_data["history"] = [ + entry for entry in history + if entry.get("timestamp", "").split("T")[0] >= cutoff_str + ] + + deleted_count += original_count - len(user_data["history"]) + + # 古いセッションを削除 + sessions = user_data.get("sessions", []) + original_session_count = len(sessions) + + user_data["sessions"] = [ + session for session in sessions + if session.get("created_at", "").split("T")[0] >= cutoff_str + ] + + deleted_count += original_session_count - len(user_data["sessions"]) + + if (original_count != len(user_data["history"]) or + original_session_count != len(user_data["sessions"])): + await self.storage.update_user_data(user_id, user_data) + + if deleted_count > 0: + logger.info(f"{deleted_count}件の古いユーザーデータを削除しました") + + return deleted_count + + except Exception as e: + logger.error(f"古いユーザーデータ削除エラー: {e}") + return 0 + + async def get_user_statistics(self) -> Dict[str, Any]: + """ + ユーザーの統計情報を取得する + + Returns: + Dict: 統計情報 + """ + try: + all_users = await self.storage.get_all_users() + today = datetime.now().strftime("%Y-%m-%d") + week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") + + stats = { + "total_users": len(all_users), + "active_users_today": 0, + "active_users_week": 0, + "total_letters": 0, + "total_requests": 0, + "total_sessions": 0, + "active_sessions": 0, + "date": today + } + + for user_id in all_users: + user_data = await self.storage.get_user_data(user_id) + + # 手紙とリクエストの総数 + stats["total_letters"] += len(user_data.get("letters", {})) + stats["total_requests"] += len(user_data.get("requests", {})) + + # セッション統計 + sessions = user_data.get("sessions", []) + stats["total_sessions"] += len(sessions) + + for session in sessions: + if session.get("is_active", False): + expires_at = datetime.fromisoformat(session["expires_at"]) + if datetime.now() < expires_at: + stats["active_sessions"] += 1 + + # アクティブユーザー統計 + last_activity = self._calculate_last_activity(user_data) + if last_activity: + activity_date = last_activity.split("T")[0] + if activity_date >= today: + stats["active_users_today"] += 1 + if activity_date >= week_ago: + stats["active_users_week"] += 1 + + return stats + + except Exception as e: + logger.error(f"ユーザー統計取得エラー: {e}") + return {"error": str(e)} + + +# テスト用の関数 +async def test_user_manager(): + """UserManagerのテスト""" + import tempfile + from async_storage_manager import AsyncStorageManager + + # 一時ディレクトリでテスト + with tempfile.TemporaryDirectory() as temp_dir: + test_file = os.path.join(temp_dir, "test_letters.json") + storage = AsyncStorageManager(test_file) + user_manager = UserManager(storage) + + print("=== UserManagerテスト開始 ===") + + # ユーザーID生成テスト + user_id = user_manager.generate_user_id() + print(f"✓ ユーザーID生成テスト: {user_id}") + + # プロファイル取得テスト + profile = await user_manager.get_user_profile(user_id) + print(f"✓ プロファイル取得テスト: {profile}") + + # 履歴更新テスト + interaction = { + "type": "letter_request", + "action": "submit_request", + "details": {"theme": "テストテーマ"} + } + updated = await user_manager.update_user_history(user_id, interaction) + print(f"✓ 履歴更新テスト: {updated}") + + # セッション作成テスト + session_info = {"ip_address": "127.0.0.1", "user_agent": "test"} + session_id = await user_manager.create_user_session(user_id, session_info) + print(f"✓ セッション作成テスト: {session_id}") + + # セッション検証テスト + valid = await user_manager.validate_user_session(user_id, session_id) + print(f"✓ セッション検証テスト: {valid}") + + # 統計情報取得テスト + stats = await user_manager.get_user_statistics() + print(f"✓ 統計情報取得テスト: {stats}") + + print("=== 全てのテストが完了しました! ===") + + +if __name__ == "__main__": + asyncio.run(test_user_manager()) \ No newline at end of file diff --git a/local_user_id_manager.py b/local_user_id_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..3dcd70e2eb46ed455086df2c4febf6bf1318a53e --- /dev/null +++ b/local_user_id_manager.py @@ -0,0 +1,241 @@ +""" +ユーザーID永続化管理モジュール +ローカル環境でユーザーIDをファイルに保存し、仮想環境を閉じても継続してプレイできるようにする +""" +import os +import json +import uuid +import logging +from datetime import datetime +from typing import Optional, Dict, Any + +logger = logging.getLogger(__name__) + +class UserIDManager: + """ユーザーIDの永続化を管理するクラス""" + + def __init__(self, storage_dir: str = "user_data"): + """ + Args: + storage_dir: ユーザーデータを保存するディレクトリ + """ + self.storage_dir = storage_dir + self.user_id_file = os.path.join(storage_dir, "user_id.json") + self._ensure_storage_dir() + + def _ensure_storage_dir(self): + """ストレージディレクトリが存在することを確認""" + try: + if not os.path.exists(self.storage_dir): + os.makedirs(self.storage_dir) + logger.info(f"ユーザーデータディレクトリを作成: {self.storage_dir}") + except Exception as e: + logger.error(f"ストレージディレクトリ作成エラー: {e}") + + def get_or_create_user_id(self) -> str: + """ + 保存されたユーザーIDを取得するか、新規作成する + + Returns: + ユーザーID + """ + try: + # 既存のユーザーIDファイルをチェック + if os.path.exists(self.user_id_file): + user_data = self._load_user_data() + if user_data and "user_id" in user_data: + user_id = user_data["user_id"] + logger.info(f"既存のユーザーIDを読み込み: {user_id[:8]}...") + + # 最終アクセス時刻を更新 + self._update_last_access(user_id) + return user_id + + # 新規ユーザーIDを作成 + user_id = self._generate_new_user_id() + self._save_user_data(user_id) + logger.info(f"新規ユーザーIDを作成: {user_id[:8]}...") + return user_id + + except Exception as e: + logger.error(f"ユーザーID取得エラー: {e}") + # フォールバック: 一時的なIDを生成 + return str(uuid.uuid4()) + + def _generate_new_user_id(self) -> str: + """新しいユーザーIDを生成""" + return str(uuid.uuid4()) + + def _load_user_data(self) -> Optional[Dict[str, Any]]: + """ユーザーデータファイルを読み込み""" + try: + with open(self.user_id_file, 'r', encoding='utf-8') as f: + data = json.load(f) + return data + except Exception as e: + logger.error(f"ユーザーデータ読み込みエラー: {e}") + return None + + def _save_user_data(self, user_id: str, game_data: Optional[Dict[str, Any]] = None): + """ユーザーデータをファイルに保存""" + try: + user_data = { + "user_id": user_id, + "created_at": datetime.now().isoformat(), + "last_access": datetime.now().isoformat(), + "version": "1.0", + "game_data": game_data or {} + } + + with open(self.user_id_file, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + + logger.info(f"ユーザーデータを保存: {self.user_id_file}") + + except Exception as e: + logger.error(f"ユーザーデータ保存エラー: {e}") + + def _update_last_access(self, user_id: str): + """最終アクセス時刻を更新""" + try: + user_data = self._load_user_data() + if user_data: + user_data["last_access"] = datetime.now().isoformat() + + with open(self.user_id_file, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + + logger.debug(f"最終アクセス時刻を更新: {user_id[:8]}...") + + except Exception as e: + logger.error(f"最終アクセス時刻更新エラー: {e}") + + def get_user_info(self) -> Optional[Dict[str, Any]]: + """ユーザー情報を取得""" + return self._load_user_data() + + def delete_user_data(self) -> bool: + """ + ユーザーデータを削除(フルリセット用) + + Returns: + 削除成功かどうか + """ + try: + if os.path.exists(self.user_id_file): + os.remove(self.user_id_file) + logger.info(f"ユーザーデータファイルを削除: {self.user_id_file}") + return True + else: + logger.info("削除対象のユーザーデータファイルが存在しません") + return True + + except Exception as e: + logger.error(f"ユーザーデータ削除エラー: {e}") + return False + + def reset_user_id(self) -> str: + """ + ユーザーIDをリセットして新規作成 + + Returns: + 新しいユーザーID + """ + try: + # 既存データを削除 + self.delete_user_data() + + # 新規IDを作成 + new_user_id = self._generate_new_user_id() + self._save_user_data(new_user_id) + + logger.info(f"ユーザーIDをリセット: {new_user_id[:8]}...") + return new_user_id + + except Exception as e: + logger.error(f"ユーザーIDリセットエラー: {e}") + return str(uuid.uuid4()) + + def is_user_data_exists(self) -> bool: + """ユーザーデータファイルが存在するかチェック""" + return os.path.exists(self.user_id_file) + + def get_storage_info(self) -> Dict[str, Any]: + """ストレージ情報を取得(デバッグ用)""" + try: + info = { + "storage_dir": self.storage_dir, + "user_id_file": self.user_id_file, + "file_exists": os.path.exists(self.user_id_file), + "dir_exists": os.path.exists(self.storage_dir) + } + + if info["file_exists"]: + stat = os.stat(self.user_id_file) + info["file_size"] = stat.st_size + info["modified_time"] = datetime.fromtimestamp(stat.st_mtime).isoformat() + + return info + + except Exception as e: + logger.error(f"ストレージ情報取得エラー: {e}") + return {"error": str(e)} + + def save_game_data(self, user_id: str, game_data: Dict[str, Any]) -> bool: + """ + ゲームデータを保存 + + Args: + user_id: ユーザーID + game_data: 保存するゲームデータ + + Returns: + 保存成功かどうか + """ + try: + user_data = self._load_user_data() + if not user_data: + # ユーザーデータが存在しない場合は新規作成 + user_data = { + "user_id": user_id, + "created_at": datetime.now().isoformat(), + "version": "1.0" + } + + # ゲームデータを更新 + user_data["game_data"] = game_data + user_data["last_access"] = datetime.now().isoformat() + + with open(self.user_id_file, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + + logger.info(f"ゲームデータを保存: {user_id[:8]}...") + return True + + except Exception as e: + logger.error(f"ゲームデータ保存エラー: {e}") + return False + + def load_game_data(self, user_id: str) -> Optional[Dict[str, Any]]: + """ + ゲームデータを読み込み + + Args: + user_id: ユーザーID + + Returns: + ゲームデータ(存在しない場合はNone) + """ + try: + user_data = self._load_user_data() + if user_data and user_data.get("user_id") == user_id: + game_data = user_data.get("game_data", {}) + logger.info(f"ゲームデータを読み込み: {user_id[:8]}... (データサイズ: {len(str(game_data))}文字)") + return game_data + else: + logger.info(f"ゲームデータが見つかりません: {user_id[:8]}...") + return None + + except Exception as e: + logger.error(f"ゲームデータ読み込みエラー: {e}") + return None \ No newline at end of file diff --git a/main_app.py b/main_app.py new file mode 100644 index 0000000000000000000000000000000000000000..352ba57a7ed5106c003a012d125dcb7c28129373 --- /dev/null +++ b/main_app.py @@ -0,0 +1,3189 @@ +""" +麻理チャット&手紙生成 統合アプリケーション +""" +import streamlit as st +import logging +import os +import asyncio +import sys +import time +from datetime import datetime +from dotenv import load_dotenv +from contextlib import contextmanager + +# --- 基本設定 --- +# 非同期処理の問題を解決 (Windows向け) +if sys.platform.startswith('win'): + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + +# .envファイルから環境変数を読み込み +load_dotenv() + +# ロガー設定 +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# --- セッション管理サーバー自動起動 --- +def start_session_server(): + """ + セッション管理サーバーを自動起動する + Hugging Face Spacesでの実行時に必要 + """ + import subprocess + import threading + import requests + import time + + def check_server_running(timeout=2): + """サーバーが起動しているかチェック""" + # Hugging Face Spacesでの実行を考慮してホストを動的に決定 + hosts_to_try = ["127.0.0.1", "localhost", "0.0.0.0"] + port = 8000 + + for host in hosts_to_try: + try: + response = requests.get(f"http://{host}:{port}/health", timeout=timeout) + if response.status_code == 200: + logger.debug(f"サーバー接続成功: {host}:{port}") + return True + except Exception: + continue + return False + + def run_server(): + """バックグラウンドでサーバーを起動""" + try: + logger.info("セッション管理サーバーをバックグラウンドで起動中...") + + # uvicornでサーバー起動(Hugging Face Spaces対応) + import uvicorn + + # 実行環境に応じてホストを決定 + is_spaces = os.getenv("SPACE_ID") is not None + host = "0.0.0.0" if is_spaces else "127.0.0.1" + + uvicorn.run( + "session_api_server:app", + host=host, + port=8000, + log_level="warning", # ログレベルを下げてStreamlitログと混在を防ぐ + access_log=False # アクセスログを無効化 + ) + except Exception as e: + logger.error(f"サーバー起動エラー: {e}") + + # 既にサーバーが起動しているかチェック + if check_server_running(): + logger.info("✅ セッション管理サーバーは既に起動しています") + return True + + try: + # バックグラウンドでサーバー起動 + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + # サーバー起動待機(最大15秒、Hugging Face Spacesでは時間がかかる場合がある) + max_wait = 15 + for i in range(max_wait): + time.sleep(1) + if check_server_running(): + logger.info(f"✅ セッション管理サーバー起動成功 ({i+1}秒)") + return True + if i < max_wait - 1: # 最後の試行以外でログ出力 + logger.info(f"サーバー起動待機中... ({i+1}/{max_wait})") + + logger.warning("⚠️ セッション管理サーバー起動タイムアウト - フォールバックモードで継続") + return False + + except Exception as e: + logger.error(f"サーバー起動処理エラー: {e}") + return False + +# アプリケーション起動時にサーバーを自動起動 +if 'server_started' not in st.session_state: + st.session_state.server_started = start_session_server() + if st.session_state.server_started: + logger.info("🚀 セッション管理サーバー起動完了") + else: + logger.warning("⚠️ セッション管理サーバー起動失敗 - フォールバックモードで動作") + + +# --- 必要なモジュールのインポート --- + +# << 麻理チャット用モジュール >> +from core_dialogue import DialogueGenerator +from core_sentiment import SentimentAnalyzer +from core_rate_limiter import RateLimiter +from core_scene_manager import SceneManager # 復元したモジュール +from core_memory_manager import MemoryManager +from components_chat_interface import ChatInterface +from components_status_display import StatusDisplay +from components_dog_assistant import DogAssistant +from components_tutorial import TutorialManager +from session_manager import SessionManager, get_session_manager, validate_session_state, perform_detailed_session_validation +from session_api_client import SessionAPIClient +from user_id_manager import UserIDManager # ユーザーID永続化管理 +from persistent_user_manager import PersistentUserManager # HF Spaces永続ストレージ管理 +# << 手紙生成用モジュール >> +from letter_config import Config +from letter_logger import setup_logger as setup_letter_logger +from letter_generator import LetterGenerator +from letter_request_manager import RequestManager +from letter_user_manager import UserManager +from async_storage_manager import AsyncStorageManager +from async_rate_limiter import AsyncRateLimitManager + +# --- 定数 --- +MAX_INPUT_LENGTH = 200 +MAX_HISTORY_TURNS = 50 + +def get_event_loop(): + """ + セッションごとに単一のイベントループを取得または作成する + """ + try: + # 既に実行中のループがあればそれを返す + return asyncio.get_running_loop() + except RuntimeError: + # 実行中のループがなければ、新しく作成 + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + +def run_async(coro): + """ + Streamlitのセッションで共有されたイベントループを使って非同期関数を実行する + """ + try: + # 既存のループがあるかチェック + loop = asyncio.get_running_loop() + # 既存のループがある場合は、新しいタスクとして実行 + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(asyncio.run, coro) + return future.result() + except RuntimeError: + # 実行中のループがない場合は、新しいループで実行 + return asyncio.run(coro) + +def update_background(scene_manager: SceneManager, theme: str): + """現在のテーマに基づいて背景画像を動的に設定するCSSを注入する(重複実行防止)""" + logger.info(f"update_background関数が呼び出されました - テーマ: {theme}") + + # 背景更新スキップ機能を無効化:常に背景を更新 + logger.info(f"背景更新を実行します - テーマ: {theme} (スキップ機能無効)") + + try: + logger.info(f"背景更新を開始します - テーマ: {theme}") + + # SceneManagerから画像のURLを取得 + image_url = scene_manager.get_theme_url(theme) + if not image_url: + logger.warning(f"Theme '{theme}' has no valid image URL.") + return + + # ファイルの存在確認とBase64埋め込み + if image_url.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): + import os + import base64 + file_path = os.path.join(os.path.dirname(__file__), image_url) + if not os.path.exists(file_path): + logger.error(f"背景画像ファイルが見つかりません: {file_path}") + return + + # ローカルファイルをBase64エンコードして埋め込み + try: + with open(file_path, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode() + # ファイル拡張子から MIME タイプを判定 + if file_path.lower().endswith(('.png', '.PNG')): + mime_type = 'image/png' + elif file_path.lower().endswith(('.jpg', '.jpeg', '.JPG', '.JPEG')): + mime_type = 'image/jpeg' + elif file_path.lower().endswith(('.gif', '.GIF')): + mime_type = 'image/gif' + elif file_path.lower().endswith(('.webp', '.WEBP')): + mime_type = 'image/webp' + else: + mime_type = 'image/jpeg' # デフォルト + + css_image_url = f"url('data:{mime_type};base64,{encoded_string}')" + except Exception as e: + logger.error(f"Base64エンコードエラー: {e}") + # フォールバック: 相対パス + css_image_url = f"url('{image_url}')" + else: + # 外部URLの場合はそのまま使用 + css_image_url = f"url('{image_url}')" + + # 背景CSSを生成(直接適用 + タブエリア保護) + background_css = f""" + + """ + + # 背景CSSを最優先で適用 + st.markdown(background_css, unsafe_allow_html=True) + + # JavaScriptで強制的に背景を適用(Base64 + 正確なセレクタ) + background_js = f""" + + """ + st.markdown(background_js, unsafe_allow_html=True) + + # デバッグモード時のみ簡潔な通知 + if st.session_state.get("debug_mode", False) and image_url: + st.success(f"🖼️ 背景更新: {theme}") + + # 現在のテーマを記録(参考用のみ) + st.session_state.last_background_theme = theme + + except Exception as e: + logger.error(f"背景更新エラー: {e}") + import traceback + logger.error(f"エラーの詳細: {traceback.format_exc()}") + # フォールバック背景を適用 + fallback_css = """ + + """ + st.markdown(fallback_css, unsafe_allow_html=True) + st.session_state.last_background_theme = theme + +# --- ▼▼▼ 1. 初期化処理の一元管理 ▼▼▼ --- + +@st.cache_resource +def initialize_all_managers(): + """ + アプリケーション全体で共有する全ての管理クラスを初期化する + Streamlitのキャッシュ機能により、シングルトンとして振る舞う + """ + logger.info("Initializing all managers...") + # --- 手紙機能の依存モジュール --- + letter_storage = AsyncStorageManager(Config.STORAGE_PATH) + letter_rate_limiter = AsyncRateLimitManager(letter_storage, max_requests=Config.MAX_DAILY_REQUESTS) + user_manager = UserManager(letter_storage) + letter_request_manager = RequestManager(letter_storage, letter_rate_limiter) + letter_generator = LetterGenerator() + + request_manager = RequestManager(letter_storage, letter_rate_limiter) + + # --- チャット機能の依存モジュール --- + dialogue_generator = DialogueGenerator() + sentiment_analyzer = SentimentAnalyzer() + rate_limiter = RateLimiter() + scene_manager = SceneManager() + # memory_manager は セッション単位で作成するため、ここでは作成しない + chat_interface = ChatInterface(max_input_length=MAX_INPUT_LENGTH) + status_display = StatusDisplay() + dog_assistant = DogAssistant() + tutorial_manager = TutorialManager() + session_api_client = SessionAPIClient() + user_id_manager = UserIDManager() # ユーザーID永続化管理 + persistent_user_manager = PersistentUserManager() # HF Spaces永続ストレージ管理 + + logger.info("All managers initialized.") + return { + # 手紙用 + "user_manager": user_manager, + "request_manager": request_manager, + "letter_generator": letter_generator, + # チャット用 + "dialogue_generator": dialogue_generator, + "sentiment_analyzer": sentiment_analyzer, + "rate_limiter": rate_limiter, + "scene_manager": scene_manager, + # memory_manager は セッション単位で作成 + "chat_interface": chat_interface, + "status_display": status_display, + "dog_assistant": dog_assistant, + "tutorial_manager": tutorial_manager, + "session_api_client": session_api_client, + "user_id_manager": user_id_manager, # ユーザーID永続化管理 + "persistent_user_manager": persistent_user_manager, # HF Spaces永続ストレージ管理 + } + +def initialize_session_state(managers, force_reset_override=False): + """ + アプリケーション全体のセッションステートを初期化する + SessionManagerを使用してセッション分離を強化 + + Args: + managers: 管理クラスの辞書 + force_reset_override: 強制リセットフラグ(フルリセット時に使用) + """ + # 強制リセットフラグ(開発時用または明示的な指定) + force_reset = force_reset_override or os.getenv("FORCE_SESSION_RESET", "false").lower() == "true" + + # 初回起動時はセッション検証をスキップ + is_first_run = 'user_id' not in st.session_state + + # SessionManagerの初期化(セッション分離強化) + session_manager = get_session_manager() + + # 初回起動時以外でセッション整合性チェックを実行 + if not is_first_run and not validate_session_state(): + logger.error("Session validation failed during initialization") + # 復旧に失敗した場合は強制リセット + force_reset = True + + # HF Spaces永続ストレージ対応ユーザー管理システムを使用 + persistent_user_manager = managers["persistent_user_manager"] + user_id_manager = managers["user_id_manager"] # フォールバック用 + session_api_client = managers["session_api_client"] + + # セッションIDを取得または生成(複数回呼び出し防止) + if 'user_id' not in st.session_state or force_reset: + try: + # 永続ストレージからCookieベースでユーザーIDを取得 + session_id = persistent_user_manager.get_or_create_user_id() + logger.info(f"永続ストレージからユーザーIDを取得: {session_id[:8]}...") + except Exception as e: + logger.error(f"永続ストレージ取得エラー、フォールバック使用: {e}") + # フォールバック: 従来のローカルファイル方式 + session_id = user_id_manager.get_or_create_user_id() + logger.info(f"フォールバック: ローカルファイルからユーザーIDを取得: {session_id[:8]}...") + else: + session_id = st.session_state.user_id + logger.debug(f"既存ユーザーID使用: {session_id[:8]}...") + + # ユーザーIDとしてセッションIDを使用 + session_changed = ('user_id' not in st.session_state or + st.session_state.user_id != session_id or + force_reset) + + if session_changed: + st.session_state.user_id = session_id + + # SessionManagerにユーザーIDを設定 + session_manager.set_user_id(st.session_state.user_id) + + # セッション情報をログ出力 + session_info = { + "user_id": st.session_state.user_id[:8] + "...", # プライバシー保護のため一部のみ + "session_id": id(st.session_state), + "force_reset": force_reset, + "session_changed": session_changed, + "timestamp": datetime.now().isoformat() + } + logger.info(f"FastAPIセッション管理でユーザーセッション設定: {session_info}") + + # セッション固有の識別子を保存 + st.session_state._session_id = id(st.session_state) + else: + # 既存セッションの場合もSessionManagerにユーザーIDを設定 + if session_manager.user_id != st.session_state.user_id: + session_manager.set_user_id(st.session_state.user_id) + + logger.debug(f"既存セッション継続使用: {st.session_state.user_id[:8]}...") + + if 'chat_initialized' not in st.session_state: + st.session_state.chat_initialized = False + + if force_reset: + st.session_state.chat_initialized = False + + if not st.session_state.chat_initialized: + # 保存されたゲームデータを読み込み(永続ストレージ優先) + saved_game_data = None + try: + saved_game_data = persistent_user_manager.load_user_game_data(st.session_state.user_id) + if saved_game_data: + logger.info(f"永続ストレージからゲームデータを読み込み: {st.session_state.user_id[:8]}...") + except Exception as e: + logger.error(f"永続ストレージ読み込みエラー、フォールバック使用: {e}") + # フォールバック: 従来のローカルファイル方式 + saved_game_data = user_id_manager.load_game_data(st.session_state.user_id) + if saved_game_data: + logger.info(f"フォールバック: ローカルファイルからゲームデータを読み込み") + + if saved_game_data and not force_reset: + # 保存データから復元 + logger.info(f"保存されたゲームデータを復元: {st.session_state.user_id[:8]}...") + + initial_message = "何の用?遊びに来たの?" + st.session_state.chat = { + "messages": saved_game_data.get("messages", [{"role": "assistant", "content": initial_message, "is_initial": True}]), + "affection": saved_game_data.get("affection", 30), + "scene_params": saved_game_data.get("scene_params", {"theme": "default"}), + "limiter_state": managers["rate_limiter"].create_limiter_state(), + "scene_change_pending": None, + "ura_mode": saved_game_data.get("ura_mode", False) + } + st.session_state.memory_notifications = [] + st.session_state.affection_notifications = [] + st.session_state.debug_mode = os.getenv("DEBUG_MODE", "false").lower() == "true" + + # メモリマネージャーも復元 + st.session_state.memory_manager = MemoryManager(history_threshold=10) + if "memory_data" in saved_game_data: + # メモリデータがあれば復元(実装は後で) + pass + + logger.info(f"ゲームデータ復元完了 - 好感度: {st.session_state.chat['affection']}, メッセージ数: {len(st.session_state.chat['messages'])}") + else: + # 新規初期化 + initial_message = "何の用?遊びに来たの?" + st.session_state.chat = { + "messages": [{"role": "assistant", "content": initial_message, "is_initial": True}], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": managers["rate_limiter"].create_limiter_state(), + "scene_change_pending": None, + "ura_mode": False + } + st.session_state.memory_notifications = [] + st.session_state.affection_notifications = [] + st.session_state.debug_mode = os.getenv("DEBUG_MODE", "false").lower() == "true" + st.session_state.memory_manager = MemoryManager(history_threshold=10) + logger.info(f"新規チャット初期化完了 - 初期メッセージ: '{initial_message}'") + + st.session_state.rerun_count = 0 # rerun回数カウンター初期化 + st.session_state.chat_initialized = True + logger.info("Chat session state initialized with SessionManager.") + else: + # 既存セッションでも初期メッセージが存在するかチェック + if 'chat' in st.session_state and 'messages' in st.session_state.chat: + messages = st.session_state.chat['messages'] + # 初期メッセージが存在しない場合は復元 + if not messages or not any(msg.get('is_initial', False) for msg in messages): + initial_message = "何の用?遊びに来たの?" + initial_msg = {"role": "assistant", "content": initial_message, "is_initial": True} + + # チュートリアル保護フラグがある場合は初期メッセージを先頭に挿入 + if st.session_state.get('preserve_initial_message', False): + st.session_state.chat['messages'].insert(0, initial_msg) + logger.info("初期メッセージを復元しました(チュートリアル保護)") + # 保護フラグをクリア + st.session_state.preserve_initial_message = False + elif not messages: + # メッセージが全くない場合は初期メッセージを追加 + st.session_state.chat['messages'] = [initial_msg] + logger.info("空のメッセージリストに初期メッセージを追加しました") + + logger.info("Session resumed without reset.") + + if force_reset: + logger.info("Session force reset - all data cleared") + + + # MemoryManagerがセッション状態にない場合は作成 + if 'memory_manager' not in st.session_state: + st.session_state.memory_manager = MemoryManager(history_threshold=10) + + # 特別な記憶の通知用リストが存在しない場合は作成 + if 'memory_notifications' not in st.session_state: + st.session_state.memory_notifications = [] + + # 好感度変化の通知用リストが存在しない場合は作成 + if 'affection_notifications' not in st.session_state: + st.session_state.affection_notifications = [] + + # 裏モードフラグが存在しない場合は作成 + if 'ura_mode' not in st.session_state.chat: + st.session_state.chat['ura_mode'] = False + + # 最終的なセッション整合性チェック + if not session_manager.validate_session_integrity(): + logger.warning("Session integrity check failed after initialization") + session_manager.recover_session() + + # 初期化完了フラグを設定 + st.session_state._initialization_complete = True + + # 手紙機能用のセッションは特に追加の初期化は不要 + # (各関数内で必要なデータは都度非同期で取得するため) + +def save_game_data_to_file(managers): + """現在のゲームデータをファイルに保存(永続ストレージ対応)""" + try: + if 'user_id' not in st.session_state or 'chat' not in st.session_state: + return False + + persistent_user_manager = managers["persistent_user_manager"] + user_id_manager = managers["user_id_manager"] # フォールバック用 + + # 保存するゲームデータを構築 + game_data = { + "messages": st.session_state.chat.get("messages", []), + "affection": st.session_state.chat.get("affection", 30), + "scene_params": st.session_state.chat.get("scene_params", {"theme": "default"}), + "ura_mode": st.session_state.chat.get("ura_mode", False), + "saved_at": datetime.now().isoformat() + } + + # メモリマネージャーのデータも保存(可能であれば) + if hasattr(st.session_state, 'memory_manager'): + try: + # メモリデータを辞書形式で保存 + memory_data = { + "important_memories": getattr(st.session_state.memory_manager, 'important_memories', []), + "conversation_summary": getattr(st.session_state.memory_manager, 'conversation_summary', "") + } + game_data["memory_data"] = memory_data + except Exception as e: + logger.warning(f"メモリデータ保存エラー: {e}") + + # 永続ストレージに保存を試行 + success = False + try: + success = persistent_user_manager.save_user_game_data(st.session_state.user_id, game_data) + if success: + logger.info(f"永続ストレージにゲームデータ保存成功: 好感度={game_data['affection']}, メッセージ数={len(game_data['messages'])}") + except Exception as e: + logger.error(f"永続ストレージ保存エラー、フォールバック使用: {e}") + # フォールバック: 従来のローカルファイル方式 + success = user_id_manager.save_game_data(st.session_state.user_id, game_data) + if success: + logger.info(f"フォールバック: ローカルファイルにゲームデータ保存成功") + + return success + + except Exception as e: + logger.error(f"ゲームデータ保存エラー: {e}") + return False + + +# --- ▼▼▼ 2. UIコンポーネントの関数化 ▼▼▼ --- + +def inject_custom_css(file_path="streamlit_styles.css"): + # 常にCSSを読み込み(フラグチェックなし) + try: + with open(file_path, "r", encoding="utf-8") as f: + css_content = f.read() + st.markdown(f"", unsafe_allow_html=True) + logger.info(f"CSSファイルを読み込みました: {file_path}") + except FileNotFoundError: + logger.warning(f"CSSファイルが見つかりません: {file_path}") + # フォールバック用の基本スタイルを適用 + apply_fallback_css() + except Exception as e: + logger.error(f"CSS読み込みエラー: {e}") + apply_fallback_css() + +def apply_fallback_css(): + """フォールバック用の基本CSSを適用""" + fallback_css = """ + + """ + st.markdown(fallback_css, unsafe_allow_html=True) + logger.info("フォールバック用CSSを適用しました") + + + +def show_memory_notification(message: str): + """特別な記憶の通知をポップアップ風に表示する""" + notification_css = """ + + """ + + notification_html = f""" +
+ 🧠✨ + {message} +
+ """ + + st.markdown(notification_css + notification_html, unsafe_allow_html=True) + +def check_affection_milestone(old_affection: int, new_affection: int) -> str: + """好感度のマイルストーンに到達したかチェックする""" + milestones = { + 40: "🌸 麻理があなたに心を開き始めました!手紙をリクエストできるようになりました。", + 60: "💖 麻理があなたを信頼するようになりました!より深い会話ができるようになります。", + 80: "✨ 麻理があなたを大切な人だと思っています!特別な反応が増えるでしょう。", + 100: "🌟 麻理があなたを心から愛しています!最高の関係に到達しました!" + } + + for milestone, message in milestones.items(): + if old_affection < milestone <= new_affection: + return message + + return "" + +def show_affection_notification(change_amount: int, change_reason: str, new_affection: int, is_milestone: bool = False): + """好感度変化の通知を表示する(Streamlit標準コンポーネント使用)""" + # 好感度変化がない場合は通知しない(マイルストーン以外) + if change_amount == 0 and not is_milestone: + return + + # マイルストーン通知の場合 + if is_milestone: + st.balloons() # 特別な演出 + st.success(f"🎉 **マイルストーン達成!** {change_reason} (現在の好感度: {new_affection}/100)") + elif change_amount > 0: + # 好感度上昇 + st.success(f"💕 **+{change_amount}** {change_reason} (現在の好感度: {new_affection}/100)") + else: + # 好感度下降 + st.info(f"💔 **{change_amount}** {change_reason} (現在の好感度: {new_affection}/100)") + +def show_cute_thinking_animation(): + """かわいらしい考え中アニメーションを表示する""" + thinking_css = """ + + """ + + thinking_html = """ +
+
🤔
+
麻理が考え中...
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💭 あんたのために一生懸命考えてるんだから... +
+
+ """ + + # 音効果のJavaScript(Web Audio APIを使用した実際の音生成) + sound_js = """ + + """ + + return st.markdown(thinking_css + thinking_html + sound_js, unsafe_allow_html=True) + +@contextmanager +def cute_thinking_spinner(): + """かわいらしい考え中アニメーション付きコンテキストマネージャー""" + # アニメーション表示用のプレースホルダー + placeholder = st.empty() + + try: + # アニメーション開始 + with placeholder.container(): + show_cute_thinking_animation() + + yield + + finally: + # アニメーション終了 + placeholder.empty() + +def render_custom_chat_history(messages, chat_interface): + """カスタムチャット履歴表示エリア(マスク機能付き)""" + # チュートリアルダイアログ表示中のみ処理を一時停止(チュートリアル実施中は通常通り表示) + if st.session_state.get('tutorial_dialog_showing', False): + logger.info("チュートリアルダイアログ表示中のため、チャット履歴表示を一時停止") + return + + # チュートリアル開始/スキップ処理の瞬間のみ一時停止 + if st.session_state.get('tutorial_processing', False): + logger.info("チュートリアル開始/スキップ処理中のため、チャット履歴表示を一時停止") + return + + # デバッグ: 受け取ったメッセージの状態をログ出力 + logger.info(f"🔍 render_custom_chat_history 呼び出し: messages={len(messages) if messages else 0}件") + + # セッション状態から直接メッセージを取得(参照渡しの問題を回避) + if 'chat' not in st.session_state or 'messages' not in st.session_state.chat: + logger.error("チャットセッション状態が存在しません") + # チュートリアル中でない場合のみエラー表示 + if not st.session_state.get('tutorial_start_requested', False) and not st.session_state.get('tutorial_skip_requested', False): + st.error("チャットセッションが初期化されていません。ページを再読み込みしてください。") + return + + # セッション状態から直接取得 + session_messages = st.session_state.chat['messages'] + logger.info(f"🔍 セッション状態のメッセージ: {len(session_messages)}件") + + # 初期メッセージの存在確認と復元 + initial_messages = [msg for msg in session_messages if msg.get('is_initial', False)] + + if not initial_messages: + logger.warning("初期メッセージが見つかりません - 即座に復元します") + # 初期メッセージを先頭に挿入 + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + session_messages = st.session_state.chat['messages'] + logger.info(f"初期メッセージを復元しました - 現在のメッセージ数: {len(session_messages)}") + else: + logger.debug(f"初期メッセージ確認: {len(initial_messages)}件 - 内容: '{initial_messages[0].get('content', '')}'") + + # 最終的なメッセージ数をチェック + if not session_messages: + logger.error("メッセージリストが依然として空です") + # 強制的に初期メッセージを作成 + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + session_messages = st.session_state.chat['messages'] + logger.info("強制的に初期メッセージを作成しました") + + logger.info(f"🎯 最終的に表示するメッセージ数: {len(session_messages)}") + + # 拡張されたチャットインターフェースを使用(マスク機能付き) + chat_interface.render_chat_history(session_messages) + + +# === チャットタブの描画関数 === +def render_chat_tab(managers): + """「麻理と話す」タブのUIを描画する""" + + # 現在のタブを記録(タブ切り替え検出用) + st.session_state.last_active_tab = "chat" + + # 背景を更新 + try: + current_theme = st.session_state.chat['scene_params'].get("theme", "default") + logger.info(f"チャットタブ背景更新: テーマ '{current_theme}' を適用します") + logger.info(f"update_background関数を呼び出します...") + update_background(managers['scene_manager'], current_theme) + logger.info(f"update_background関数の呼び出しが完了しました") + except Exception as e: + logger.error(f"チャットタブ背景更新でエラーが発生: {e}") + import traceback + logger.error(f"チャットタブ背景更新エラーの詳細: {traceback.format_exc()}") + # エラーが発生してもアプリケーションは継続 + + # チュートリアル機能の自動チェック + tutorial_manager = managers['tutorial_manager'] + tutorial_manager.auto_check_completions() + + # チュートリアル案内をチャットタブに表示(テスト中) + tutorial_manager.render_chat_tutorial_guide() + + # --- サイドバー --- + with st.sidebar: + # セーフティ機能を左サイドバーに統合 + current_mode = st.session_state.chat.get('ura_mode', False) + safety_color = "#ff4757" if current_mode else "#2ed573" # 赤:解除、緑:有効 + safety_text = "セーフティ解除" if current_mode else "セーフティ有効" + safety_icon = "🔓" if current_mode else "🔒" + + # セーフティボタンのカスタムCSS + safety_css = f""" + + """ + st.markdown(safety_css, unsafe_allow_html=True) + + if st.button(f"{safety_icon} {safety_text}", type="primary" if current_mode else "secondary", + help="麻理のセーフティ機能を切り替えます", use_container_width=True): + st.session_state.chat['ura_mode'] = not current_mode + new_mode = st.session_state.chat['ura_mode'] + + # チュートリアルステップ3を完了 + tutorial_manager.check_step_completion(3, True) + + if new_mode: + st.success("🔓 セーフティ解除モードに切り替えました!") + else: + st.info("🔒 セーフティ有効モードに戻しました。") + st.rerun() + + # 好感度に応じた現在の色を計算する関数(スコープ問題回避のため外部定義) + def get_affection_color(affection_val): + if affection_val < 20: + return "#0284c7" # 青(寒色) + elif affection_val < 40: + # 青から緑へのグラデーション + ratio = (affection_val - 20) / 20 + return f"color-mix(in srgb, #0284c7 {100-ratio*100}%, #16a34a {ratio*100}%)" if ratio > 0 else "#0284c7" + elif affection_val < 60: + # 緑から黄緑へのグラデーション + ratio = (affection_val - 40) / 20 + return f"color-mix(in srgb, #16a34a {100-ratio*100}%, #65a30d {ratio*100}%)" if ratio > 0 else "#16a34a" + elif affection_val < 80: + # 黄緑からオレンジへのグラデーション + ratio = (affection_val - 60) / 20 + return f"color-mix(in srgb, #65a30d {100-ratio*100}%, #d97706 {ratio*100}%)" if ratio > 0 else "#65a30d" + else: + # オレンジから赤へのグラデーション + ratio = (affection_val - 80) / 20 + return f"color-mix(in srgb, #d97706 {100-ratio*100}%, #dc2626 {ratio*100}%)" if ratio > 0 else "#d97706" + + with st.expander("📊 ステータス", expanded=True): + affection = st.session_state.chat['affection'] + + # 好感度の文字を白くするためのCSS + affection_css = """ + + """ + st.markdown(affection_css, unsafe_allow_html=True) + st.markdown('
好感度
', unsafe_allow_html=True) + + st.metric(label="好感度", value=f"{affection} / 100") + + # 好感度に応じた動的プログレスバー + progress_value = affection / 100.0 + + # 好感度範囲に応じたdata-value属性を設定するためのCSS + if affection < 20: + value_range = "0-20" + elif affection < 40: + value_range = "20-40" + elif affection < 60: + value_range = "40-60" + elif affection < 80: + value_range = "60-80" + else: + value_range = "80-100" + + current_color = get_affection_color(affection) + + # 動的プログレスバーのCSS + dynamic_progress_css = f""" + + """ + + st.markdown(dynamic_progress_css, unsafe_allow_html=True) + st.markdown(f'
{affection}%
', unsafe_allow_html=True) + + stage_name = managers['sentiment_analyzer'].get_relationship_stage(affection) + st.markdown(f"**関係性**: {stage_name}") + + # SceneManagerから現在のテーマ名を取得 + current_theme_name = st.session_state.chat['scene_params'].get("theme", "default") + st.markdown(f"**現在のシーン**: {current_theme_name}") + + + + with st.expander("💾 データ保存"): + # 保存データの存在確認(永続ストレージ優先) + persistent_user_manager = managers["persistent_user_manager"] + user_id_manager = managers["user_id_manager"] # フォールバック用 + + has_saved_data = False + user_info = None + + try: + # 永続ストレージから確認 + user_info = persistent_user_manager.get_user_info(st.session_state.user_id) + has_saved_data = user_info is not None and "game_data" in user_info + if has_saved_data: + logger.debug("永続ストレージに保存データを確認") + except Exception as e: + logger.warning(f"永続ストレージ確認エラー、フォールバック使用: {e}") + # フォールバック: 従来のローカルファイル方式 + has_saved_data = user_id_manager.is_user_data_exists() + if has_saved_data: + user_info = user_id_manager.get_user_info() + logger.debug("フォールバック: ローカルファイルに保存データを確認") + + if has_saved_data and user_info and "game_data" in user_info: + # 保存データがある場合の情報表示 + game_data = user_info["game_data"] + if game_data: + saved_affection = game_data.get("affection", "不明") + saved_messages = len(game_data.get("messages", [])) + saved_at = game_data.get("saved_at", "不明") + if saved_at != "不明": + try: + from datetime import datetime + saved_time = datetime.fromisoformat(saved_at.replace('Z', '+00:00')) + saved_at = saved_time.strftime("%m/%d %H:%M") + except: + pass + + # ストレージタイプを表示 + storage_type = "🌐 永続ストレージ" if user_info.get("storage_type") != "local" else "📁 ローカル" + st.info(f"💾 保存データあり ({storage_type})\n好感度: {saved_affection}/100\nメッセージ: {saved_messages}件\n保存日時: {saved_at}") + + if st.button("💾 ゲームデータを保存", help="現在の進行状況(好感度、チャット履歴など)をファイルに保存します", use_container_width=True): + success = save_game_data_to_file(managers) + if success: + st.success("✅ ゲームデータを保存しました!") + # 保存されたデータの概要を表示 + if 'chat' in st.session_state: + affection = st.session_state.chat.get('affection', 30) + message_count = len(st.session_state.chat.get('messages', [])) + st.info(f"📊 保存内容\n好感度: {affection}/100\nメッセージ: {message_count}件") + else: + st.error("❌ ゲームデータの保存に失敗しました。") + + with st.expander("⚙️ 設定"): + # 設定ボタン内の表示を大きくするCSS + settings_css = """ + + """ + st.markdown(settings_css, unsafe_allow_html=True) + + # 設定コンテンツをラップ + st.markdown('
', unsafe_allow_html=True) + + # ... (エクスポートやリセットボタンのロジックは省略) ... + if st.button("🔄 会話をリセット", type="secondary", use_container_width=True, help="あなたの会話履歴のみをリセットします(他のユーザーには影響しません)"): + # チャット履歴を完全にリセット + st.session_state.chat['messages'] = [{"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}] + st.session_state.chat['affection'] = 30 + st.session_state.chat['scene_params'] = {"theme": "default"} + st.session_state.chat['limiter_state'] = managers['rate_limiter'].create_limiter_state() + st.session_state.chat['ura_mode'] = False # 裏モードもリセット + + # メモリマネージャーをクリア + st.session_state.memory_manager.clear_memory() + + # Streamlitの内部チャット状態もクリア + if 'messages' in st.session_state: + del st.session_state.messages + if 'last_sent_message' in st.session_state: + del st.session_state.last_sent_message + if 'user_message_input' in st.session_state: + del st.session_state.user_message_input + if 'message_flip_states' in st.session_state: + del st.session_state.message_flip_states + + # 新しいセッションIDを生成(完全リセット) + session_api_client = managers["session_api_client"] + + # セッションをリセット + new_session_id = session_api_client.reset_session() + st.session_state.user_id = new_session_id + + st.success("会話を完全にリセットしました(新しいセッションとして開始)") + st.rerun() + + # フルリセットボタン(Cookie含む完全リセット) + st.markdown("---") + st.markdown("**⚠️ 危険な操作**") + + if st.button("🔥 フルリセット(Cookie含む)", + type="secondary", + use_container_width=True, + help="Cookie含む全データを完全にリセットします(ブラウザセッションも新規作成)"): + + # 確認ダイアログ + if 'full_reset_confirm' not in st.session_state: + st.session_state.full_reset_confirm = False + + if not st.session_state.full_reset_confirm: + st.session_state.full_reset_confirm = True + st.warning("⚠️ 本当にフルリセットしますか?この操作は取り消せません。") + st.info("Cookie削除→新規セッション作成を実行します。もう一度ボタンを押してください。") + st.rerun() + else: + # フルリセット実行 + session_api_client = managers["session_api_client"] + + try: + # プログレスバーで進行状況を表示 + progress_bar = st.progress(0) + status_text = st.empty() + + status_text.text("🔄 フルリセット開始...") + progress_bar.progress(10) + + # 1. 永続ストレージとローカルユーザーデータ削除 + status_text.text("🗑️ ユーザーデータ削除中...") + progress_bar.progress(20) + + persistent_user_manager = managers["persistent_user_manager"] + user_id_manager = managers["user_id_manager"] + + # 永続ストレージから削除 + persistent_data_deleted = False + try: + persistent_data_deleted = persistent_user_manager.delete_user_data(st.session_state.user_id) + except Exception as e: + logger.error(f"永続ストレージ削除エラー: {e}") + + # ローカルファイルからも削除(フォールバック) + local_data_deleted = user_id_manager.delete_user_data() + + user_data_deleted = persistent_data_deleted or local_data_deleted + + # 2. フルリセット実行(Cookie削除→新規セッション作成) + status_text.text("🍪 Cookie削除中...") + progress_bar.progress(40) + + reset_result = session_api_client.full_reset_session() + + if reset_result['success']: + status_text.text("✅ Cookie削除完了、新規セッション作成中...") + progress_bar.progress(60) + + # 2. Streamlitセッション状態を完全クリア + keys_to_clear = list(st.session_state.keys()) + for key in keys_to_clear: + if key not in ['_session_id', 'session_info']: # 必要なキーは保持 + del st.session_state[key] + + # CSS読み込みフラグもリセット + st.session_state.css_loaded = False + st.session_state.last_background_theme = '' + st.session_state._initialization_complete = False + + # メッセージ処理キャッシュもクリア + cache_keys_to_clear = [key for key in st.session_state.keys() if key.startswith('processed_')] + for cache_key in cache_keys_to_clear: + del st.session_state[cache_key] + + status_text.text("🔄 セッション状態初期化中...") + progress_bar.progress(80) + + # 3. 新しいユーザーIDを設定 + if reset_result.get('new_session_id'): + # 完全なセッションIDを取得(表示用は短縮版) + full_session_id = st.session_state.session_info.get('session_id') + st.session_state.user_id = full_session_id + + # 4. 初期状態を再構築(強制リセット) + initialize_session_state(managers, force_reset_override=True) + + # 5. MemoryManagerの完全クリア(念のため) + if hasattr(st.session_state, 'memory_manager'): + st.session_state.memory_manager.clear_memory() + logger.info("MemoryManager完全クリア実行") + + # 6. SessionManagerのデータもリセット + session_manager = get_session_manager() + session_manager.reset_session_data() + logger.info("SessionManagerデータリセット実行") + + status_text.text("🎉 フルリセット完了!") + progress_bar.progress(100) + + # 成功メッセージ + st.success(f"🔥 フルリセット完了!") + st.info(f"📁 ローカルユーザーデータ削除: {'✅' if user_data_deleted else '❌'}") + st.info(f"📊 Cookie削除: {'✅' if reset_result.get('cookie_reset') else '❌'}") + st.info(f"🆕 新規セッション: {'✅' if reset_result.get('session_created') else '❌'}") + st.info(f"🔄 旧→新: {reset_result.get('old_session_id')} → {reset_result.get('new_session_id')}") + + # 自動リロード + st.info("⏳ 3秒後に自動でページを再読み込みします...") + reload_js = """ + + """ + st.markdown(reload_js, unsafe_allow_html=True) + + else: + st.error(f"❌ フルリセット失敗: {reset_result.get('message', '不明なエラー')}") + st.info("通常のリセットを試すか、ページを手動で再読み込みしてください。") + + except Exception as e: + logger.error(f"フルリセットエラー: {e}") + st.error(f"❌ フルリセットに失敗しました: {str(e)}") + st.info("通常のリセットを試すか、ページを手動で再読み込みしてください。") + + # 確認フラグをリセット + st.session_state.full_reset_confirm = False + + # 設定コンテンツのHTMLタグを閉じる + st.markdown('
', unsafe_allow_html=True) + + # チュートリアル案内をサイドバーに表示 + tutorial_manager.render_tutorial_sidebar() + + if False: # デバッグモードを完全に無効化 + with st.expander("🛠️ デバッグ情報", expanded=False): + # SessionManagerから詳細情報を取得 + session_manager = get_session_manager() + session_info = session_manager.get_session_info() + isolation_status = session_manager.get_isolation_status() + + # 検証履歴と復旧履歴を取得 + validation_history = session_manager.get_validation_history(limit=10) + recovery_history = session_manager.get_recovery_history(limit=10) + + # セッション分離詳細情報を構築 + session_isolation_details = { + "session_integrity": { + "status": "✅ 正常" if session_info["is_consistent"] else "❌ 不整合", + "session_id_match": session_info["session_id"] == session_info["current_session_id"], + "original_session_id": session_info["session_id"], + "current_session_id": session_info["current_session_id"], + "stored_session_id": session_info["stored_session_id"], + "user_id": session_info["user_id"], + "session_age_minutes": round(session_info["session_age_seconds"] / 60, 2), + "last_validated": session_info["last_validated"] + }, + "validation_metrics": { + "total_validations": session_info["validation_count"], + "total_recoveries": session_info["recovery_count"], + "validation_history_size": session_info["validation_history_count"], + "recovery_history_size": session_info["recovery_history_count"], + "success_rate": round((session_info["validation_count"] - session_info["recovery_count"]) / max(session_info["validation_count"], 1) * 100, 2) if session_info["validation_count"] > 0 else 100 + }, + "component_isolation": isolation_status["component_isolation"], + "data_integrity": isolation_status["data_integrity"] + } + + # FastAPIセッション状態を取得 + session_api_client = managers.get("session_api_client") + api_session_status = session_api_client.get_session_status() if session_api_client else {} + + # Cookie状態を取得 + cookie_status = session_api_client.get_cookie_status() if session_api_client else {} + + # 拡張されたデバッグ情報 + enhanced_debug_info = { + "session_isolation_details": session_isolation_details, + "isolation_status": isolation_status, + "session_manager_info": { + "session_id": session_info["session_id"], + "current_session_id": session_info["current_session_id"], + "user_id": session_info["user_id"], + "is_consistent": session_info["is_consistent"], + "validation_count": session_info["validation_count"], + "recovery_count": session_info["recovery_count"], + "session_age_seconds": session_info["session_age_seconds"], + "created_at": session_info["created_at"], + "last_validated": session_info["last_validated"] + }, + "fastapi_session_info": api_session_status, + "cookie_status": cookie_status, + "chat_state": { + "affection": st.session_state.chat['affection'], + "theme": st.session_state.chat['scene_params']['theme'], + "messages_count": len(st.session_state.chat['messages']), + "ura_mode": st.session_state.chat.get('ura_mode', False), + "limiter_state_present": 'limiter_state' in st.session_state.chat, + "scene_change_pending": st.session_state.chat.get('scene_change_pending') + }, + "memory_state": { + "cache_size": len(st.session_state.memory_manager.important_words_cache), + "special_memories": len(st.session_state.memory_manager.special_memories), + "memory_manager_type": type(st.session_state.memory_manager).__name__, + "memory_manager_id": id(st.session_state.memory_manager) + }, + "system_state": { + "session_keys": list(st.session_state.keys()), + "session_keys_count": len(st.session_state.keys()), + "notifications_pending": { + "affection": len(st.session_state.affection_notifications), + "memory": len(st.session_state.memory_notifications) + }, + "streamlit_session_id": st.session_state.get('_session_id', 'unknown') + } + } + + # タブ形式でデバッグ情報を整理(拡張版) + debug_tab1, debug_tab2, debug_tab3, debug_tab4, debug_tab5, debug_tab6 = st.tabs([ + "🔍 セッション分離", "📊 基本情報", "🍪 Cookie状態", "✅ 検証履歴", "🔧 復旧履歴", "⚙️ システム詳細" + ]) + + with debug_tab1: + st.markdown("### 🔒 セッション分離状態") + + # 手動検証ボタン + col_btn1, col_btn2, col_btn3 = st.columns(3) + with col_btn1: + if st.button("🔍 手動検証実行", help="セッション整合性を手動で検証します"): + validation_result = validate_session_state() + if validation_result: + st.success("✅ セッション検証成功") + else: + st.error("❌ セッション検証失敗") + st.rerun() + + with col_btn2: + if st.button("📋 詳細検証実行", help="詳細なセッション検証を実行します"): + detailed_issues = perform_detailed_session_validation(session_manager) + if not detailed_issues: + st.success("✅ 詳細検証: 問題なし") + else: + st.warning(f"⚠️ 詳細検証: {len(detailed_issues)}件の問題を検出") + for issue in detailed_issues: + severity_icon = "🔴" if issue['severity'] == 'critical' else "🟡" + st.write(f"{severity_icon} **{issue['type']}**: {issue['description']}") + + with col_btn3: + if st.button("🔄 強制復旧実行", help="セッション状態を強制的に復旧します"): + session_manager.recover_session() + st.info("🔄 セッション復旧を実行しました") + st.rerun() + + st.markdown("---") + + # セッション整合性ステータス + col1, col2 = st.columns(2) + with col1: + st.metric( + "セッション整合性", + session_isolation_details["session_integrity"]["status"], + delta=None + ) + st.metric( + "検証成功率", + f"{session_isolation_details['validation_metrics']['success_rate']}%", + delta=None + ) + + with col2: + st.metric( + "総検証回数", + session_isolation_details["validation_metrics"]["total_validations"], + delta=None + ) + st.metric( + "復旧実行回数", + session_isolation_details["validation_metrics"]["total_recoveries"], + delta=None + ) + + # コンポーネント分離状態 + st.markdown("#### 🧩 コンポーネント分離状態") + isolation_data = session_isolation_details["component_isolation"] + + for component, is_isolated in isolation_data.items(): + status_icon = "✅" if is_isolated else "❌" + component_name = { + "chat_isolated": "チャット機能", + "memory_isolated": "メモリ管理", + "notifications_isolated": "通知システム", + "rate_limit_isolated": "レート制限" + }.get(component, component) + + st.write(f"{status_icon} **{component_name}**: {'分離済み' if is_isolated else '未分離'}") + + # データ整合性 + st.markdown("#### 📋 データ整合性") + integrity_data = session_isolation_details["data_integrity"] + + col1, col2 = st.columns(2) + with col1: + st.metric("チャットメッセージ数", integrity_data["chat_messages_count"]) + st.metric("メモリキャッシュサイズ", integrity_data["memory_cache_size"]) + + with col2: + st.metric("特別な記憶数", integrity_data["special_memories_count"]) + pending_total = sum(integrity_data["pending_notifications"].values()) + st.metric("保留中通知数", pending_total) + + # セッションID詳細 + st.markdown("#### 🆔 セッションID詳細") + session_id_info = session_isolation_details["session_integrity"] + + st.text(f""" +セッション整合性: {session_id_info['status']} +オリジナルID: {session_id_info['original_session_id']} +現在のID: {session_id_info['current_session_id']} +保存されたID: {session_id_info['stored_session_id']} +ユーザーID: {session_id_info['user_id']} +セッション継続時間: {session_id_info['session_age_minutes']} 分 +最終検証時刻: {session_id_info['last_validated'][:19]} + """) + + with debug_tab2: + st.markdown("### 📊 基本セッション情報") + st.text(str({ + "session_manager": enhanced_debug_info["session_manager_info"], + "chat_state": enhanced_debug_info["chat_state"], + "memory_state": enhanced_debug_info["memory_state"] + })) + + with debug_tab3: + st.markdown("### 🍪 Cookie状態") + + if cookie_status: + # Cookie概要 + col1, col2, col3 = st.columns(3) + with col1: + st.metric("Cookie数", cookie_status.get('count', 0)) + with col2: + has_session = cookie_status.get('has_session_cookie', False) + st.metric("セッションCookie", "✅ あり" if has_session else "❌ なし") + with col3: + if st.button("🔄 Cookie状態更新", help="Cookie状態を再取得します"): + st.rerun() + + # Cookie詳細 + if cookie_status.get('cookies'): + st.markdown("#### Cookie詳細") + for i, cookie in enumerate(cookie_status['cookies']): + with st.expander(f"Cookie {i+1}: {cookie.get('name', 'unknown')}"): + st.text(str(cookie)) + else: + st.info("現在Cookieは設定されていません") + + # Cookie操作ボタン + st.markdown("#### Cookie操作") + col_cookie1, col_cookie2 = st.columns(2) + with col_cookie1: + if st.button("🗑️ Cookie削除テスト", help="Cookieを削除してテストします"): + try: + session_api_client.session.cookies.clear() + st.success("Cookie削除完了") + st.rerun() + except Exception as e: + st.error(f"Cookie削除エラー: {e}") + + with col_cookie2: + if st.button("🔥 フルリセットテスト", help="フルリセット機能をテストします"): + try: + reset_result = session_api_client.full_reset_session() + if reset_result['success']: + st.success(f"フルリセット成功: {reset_result['message']}") + else: + st.error(f"フルリセット失敗: {reset_result['message']}") + st.text(str(reset_result)) + except Exception as e: + st.error(f"フルリセットテストエラー: {e}") + else: + st.warning("Cookie状態を取得できませんでした") + + with debug_tab4: + st.markdown("### ✅ セッション検証履歴") + if validation_history: + st.write(f"**最新の検証履歴(最大10件):** 総検証回数 {session_info['validation_count']} 回") + + # 検証履歴のサマリー + recent_validations = validation_history[-5:] if len(validation_history) >= 5 else validation_history + success_count = sum(1 for v in recent_validations if v['is_consistent']) + + col1, col2, col3 = st.columns(3) + with col1: + st.metric("直近5回の成功率", f"{success_count}/{len(recent_validations)}") + with col2: + st.metric("最新検証結果", "✅ 成功" if validation_history[-1]['is_consistent'] else "❌ 失敗") + with col3: + st.metric("検証間隔", f"約{round((datetime.now() - datetime.fromisoformat(validation_history[-1]['timestamp'].replace('Z', '+00:00').replace('+00:00', ''))).total_seconds() / 60, 1)}分前") + + # 詳細な検証履歴 + for i, record in enumerate(reversed(validation_history)): + status_icon = "✅" if record['is_consistent'] else "❌" + timestamp = record['timestamp'][:19].replace('T', ' ') + + with st.expander(f"{status_icon} 検証 #{record['validation_count']} - {timestamp}", expanded=(i==0)): + col1, col2 = st.columns(2) + + with col1: + st.write("**基本情報:**") + st.write(f"- 検証時刻: {timestamp}") + st.write(f"- 検証回数: #{record['validation_count']}") + st.write(f"- 結果: {'✅ 整合性OK' if record['is_consistent'] else '❌ 不整合検出'}") + st.write(f"- ユーザーID: {record['user_id']}") + + with col2: + st.write("**セッションID情報:**") + st.write(f"- オリジナル: {record['original_session_id']}") + st.write(f"- 現在: {record['current_session_id']}") + st.write(f"- 保存済み: {record['stored_session_id']}") + st.write(f"- セッションキー数: {record['session_keys_count']}") + else: + st.info("検証履歴がありません") + + with debug_tab4: + st.markdown("### 🔧 セッション復旧履歴") + if recovery_history: + st.write(f"**復旧履歴:** 総復旧回数 {session_info['recovery_count']} 回") + + # 復旧履歴のサマリー + if recovery_history: + last_recovery = recovery_history[-1] + time_since_recovery = (datetime.now() - datetime.fromisoformat(last_recovery['timestamp'].replace('Z', '+00:00').replace('+00:00', ''))).total_seconds() / 60 + + col1, col2 = st.columns(2) + with col1: + st.metric("最新復旧", f"約{round(time_since_recovery, 1)}分前") + with col2: + st.metric("復旧タイプ", last_recovery.get('recovery_type', 'unknown')) + + # 詳細な復旧履歴 + for record in reversed(recovery_history): + timestamp = record['timestamp'][:19].replace('T', ' ') + recovery_type = record.get('recovery_type', 'unknown') + + with st.expander(f"🔧 復旧 #{record['recovery_count']} - {timestamp} ({recovery_type})", expanded=True): + col1, col2 = st.columns(2) + + with col1: + st.write("**復旧情報:**") + st.write(f"- 復旧時刻: {timestamp}") + st.write(f"- 復旧回数: #{record['recovery_count']}") + st.write(f"- 復旧タイプ: {recovery_type}") + st.write(f"- ユーザーID: {record['user_id']}") + + with col2: + st.write("**セッションID変更:**") + st.write(f"- 変更前: {record['old_session_id']}") + st.write(f"- 変更後: {record['new_session_id']}") + st.write(f"- セッションキー数: {record['session_keys_count']}") + st.write(f"- 復旧時検証回数: {record['validation_count_at_recovery']}") + else: + st.success("復旧履歴がありません(正常な状態です)") + + with debug_tab5: + st.markdown("### 🔧 復旧履歴") + if recovery_history: + st.write(f"**復旧履歴(最大10件):** 総復旧回数 {session_info['recovery_count']} 回") + + # 復旧履歴のサマリー + recent_recoveries = recovery_history[-5:] if len(recovery_history) >= 5 else recovery_history + + col1, col2 = st.columns(2) + with col1: + st.metric("直近の復旧回数", len(recent_recoveries)) + with col2: + if recent_recoveries: + last_recovery = recent_recoveries[-1] + st.metric("最終復旧", last_recovery['timestamp'][:19]) + + # 復旧履歴の詳細表示 + for i, recovery in enumerate(reversed(recovery_history)): + with st.expander(f"復旧 #{len(recovery_history)-i}: {recovery['timestamp'][:19]}"): + st.text(str(recovery)) + else: + st.success("復旧履歴がありません(正常な状態です)") + + with debug_tab6: + st.markdown("### ⚙️ システム詳細情報") + st.text(str(enhanced_debug_info["system_state"])) + + + # ポチ機能の統計(本格実装) + st.markdown("---") + st.markdown("### 🐕 ポチ機能統計") + flip_states = st.session_state.get('message_flip_states', {}) + st.markdown(f"**フリップ状態数**: {len(flip_states)}") + if flip_states: + st.text(str(flip_states)) + + # 追加のシステム情報 + st.markdown("#### 🔧 技術詳細") + st.text(f""" +Python オブジェクトID: +- st.session_state: {id(st.session_state)} +- SessionManager: {id(session_manager)} +- MemoryManager: {enhanced_debug_info['memory_state']['memory_manager_id']} + +環境変数: +- DEBUG_MODE: {os.getenv('DEBUG_MODE', 'false')} +- FORCE_SESSION_RESET: {os.getenv('FORCE_SESSION_RESET', 'false')} + +Streamlit情報: +- セッション状態キー数: {len(st.session_state.keys())} +- 内部セッションID: {st.session_state.get('_session_id', 'unknown')} + """) + + # --- メインコンテンツ --- + st.title("💬 麻理チャット") + st.markdown("*捨てられたアンドロイド「麻理」との対話*") + + # 好感度変化の通知を表示(テスト中) + if st.session_state.get('affection_notifications'): + for notification in st.session_state.affection_notifications: + show_affection_notification( + notification["change_amount"], + notification["change_reason"], + notification["new_affection"], + notification.get("is_milestone", False) + ) + # 通知を表示したらクリア + st.session_state.affection_notifications = [] + + # 特別な記憶の通知を表示(テスト中) + if st.session_state.get('memory_notifications'): + for notification in st.session_state.memory_notifications: + show_memory_notification(notification) + # 通知を表示したらクリア + st.session_state.memory_notifications = [] + + # チュートリアル用のexpander(テスト中) + col1, col2, col3 = st.columns([1, 2, 1]) + with col2: + with st.expander("📖 初めてチャットする人へ", expanded=False): + st.markdown(""" + ### 🤖 麻理について + 麻理は感情を持つアンドロイドです。あなたとの会話を通じて関係性が変化していきます。 + + ### 💝 好感度システム + - **会話の内容**によって好感度が上下します + - **優しい言葉**をかけると好感度が上がります + - **冷たい態度**だと好感度が下がることも... + - サイドバーで現在の好感度を確認できます + + ### 🐕 本音表示機能 + 特定の場所について話すと、背景が自動的に変わります: + - 🏖️ **ビーチ**や**海**の話 → 夕日のビーチ + - ⛩️ **神社**や**お参り**の話 → 神社の境内 + - ☕ **カフェ**や**コーヒー**の話 → 午後のカフェ + - 🎨 **美術館**や**アート**の話 → 夜の美術館 + - 🎆 **お祭り**や**花火**の話 → 夜祭り + + ### 💬 会話のコツ + 1. **自然な会話**を心がけてください + 2. **質問**をすると麻理が詳しく答えてくれます + 3. **感情**を込めた言葉は特に反応が良いです + 4. **200文字以内**でメッセージを送ってください + + ### ⚙️ 便利な機能 + - **サイドバー**:好感度やシーン情報を確認 + - **会話履歴**:過去の会話を振り返り + - **リセット機能**:新しい関係から始めたい時に + + --- + **準備ができたら、下のチャット欄で麻理に話しかけてみてください!** 😊 + """) + + st.markdown("---") + + # カスタムチャット履歴表示エリア(マスク機能付き) + # セッション状態の安全性チェック + if 'chat' not in st.session_state: + logger.error("チャットセッション状態が存在しません - 初期化を実行") + st.error("チャットセッションが初期化されていません。ページを再読み込みしてください。") + return + + if 'messages' not in st.session_state.chat: + logger.warning("メッセージリストが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + logger.info("メッセージリストを初期化しました") + + # チャット履歴を表示(安全な標準版を使用) + # render_custom_chat_history(st.session_state.chat['messages'], managers['chat_interface']) + + # 標準のStreamlitチャット表示(HTMLタグ混入を防ぐ) + for message in st.session_state.chat['messages']: + role = message.get("role", "user") + content = message.get("content", "") + is_initial = message.get("is_initial", False) + + # HTMLタグとStreamlitクラス名を完全に除去 + import re + import html + clean_content = re.sub(r'<[^>]*>', '', content) + clean_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_content) + clean_content = re.sub(r'class="[^"]*"', '', clean_content) + clean_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_content) + clean_content = re.sub(r'\s+', ' ', clean_content).strip() + + with st.chat_message(role): + if is_initial: + st.markdown(f"**[初期メッセージ]** {clean_content}") + else: + # 隠された真実の処理 + has_hidden_content, visible_content, hidden_content = managers['chat_interface']._detect_hidden_content(clean_content) + if has_hidden_content and role == "assistant": + show_all_hidden = st.session_state.get('show_all_hidden', False) + if show_all_hidden: + st.markdown(f"**表面:** {visible_content}") + st.markdown(f"🐕 **本音:** {hidden_content}") + else: + st.markdown(visible_content) + else: + st.markdown(clean_content) + + # メッセージ処理ロジック + def process_chat_message(message: str): + response = None + try: + logger.info(f"🚀 process_chat_message開始 - メッセージ: '{message}'") + # チュートリアルステップ1を完了(メッセージ送信) + tutorial_manager.check_step_completion(1, True) + + # セッション検証を処理開始時に実行 + if not validate_session_state(): + logger.error("Session validation failed at message processing start") + return "(申し訳ありません。システムに問題が発生しました。ページを再読み込みしてください。)" + + # レート制限チェック + if 'limiter_state' not in st.session_state.chat: + st.session_state.chat['limiter_state'] = managers['rate_limiter'].create_limiter_state() + + limiter_state = st.session_state.chat['limiter_state'] + if not managers['rate_limiter'].check_limiter(limiter_state): + st.session_state.chat['limiter_state'] = limiter_state + return "(…少し話すのが速すぎる。もう少し、ゆっくり話してくれないか?)" + + st.session_state.chat['limiter_state'] = limiter_state + + # 会話履歴を正しく構築(現在のメッセージは含まない) + # 注意: この時点では現在のユーザーメッセージはまだ履歴に追加されていない + # 初期メッセージ(is_initial=True)を履歴から除外して会話ペアを構築 + non_initial_messages = [msg for msg in st.session_state.chat['messages'] + if not msg.get('is_initial', False)] + + # 会話ペアを時系列順に構築 + history = [] + user_msgs = [] + assistant_msgs = [] + + for msg in non_initial_messages: + if msg['role'] == 'user': + user_msgs.append(msg['content']) + elif msg['role'] == 'assistant': + assistant_msgs.append(msg['content']) + + # ユーザーとアシスタントのメッセージをペアにする(最大5ターン) + max_turns = min(5, min(len(user_msgs), len(assistant_msgs))) + for i in range(max_turns): + if i < len(user_msgs) and i < len(assistant_msgs): + history.append((user_msgs[i], assistant_msgs[i])) + + # 初回の場合は空の履歴になる(これが正しい動作) + logger.info(f"📚 構築された履歴: {len(history)}ターン") + if st.session_state.get('debug_mode', False): + logger.info(f"🔍 全メッセージ数: {len(st.session_state.chat['messages'])}") + logger.info(f"🔍 非初期メッセージ数: {len(non_initial_messages)}") + logger.info(f"🔍 ユーザーメッセージ数: {len(user_msgs)}") + logger.info(f"🔍 アシスタントメッセージ数: {len(assistant_msgs)}") + + # 好感度更新(初期メッセージを除外) + old_affection = st.session_state.chat['affection'] + non_initial_messages = [msg for msg in st.session_state.chat['messages'] + if not msg.get('is_initial', False)] + affection, change_amount, change_reason = managers['sentiment_analyzer'].update_affection( + message, st.session_state.chat['affection'], non_initial_messages + ) + st.session_state.chat['affection'] = affection + stage_name = managers['sentiment_analyzer'].get_relationship_stage(affection) + + # 好感度変化があった場合は通知を追加 + if change_amount != 0: + affection_notification = { + "change_amount": change_amount, + "change_reason": change_reason, + "new_affection": affection, + "old_affection": old_affection + } + st.session_state.affection_notifications.append(affection_notification) + + # 特定の好感度レベルに到達した時の特別な通知 + milestone_reached = check_affection_milestone(old_affection, affection) + if milestone_reached: + milestone_notification = { + "change_amount": 0, # マイルストーン通知は変化量0で特別扱い + "change_reason": milestone_reached, + "new_affection": affection, + "old_affection": old_affection, + "is_milestone": True + } + st.session_state.affection_notifications.append(milestone_notification) + + # シーン変更検知(強化版 + デバッグ) + current_theme = st.session_state.chat['scene_params']['theme'] + logger.info(f"🎬 シーン変更検知開始 - 現在のテーマ: {current_theme}") + logger.info(f"📝 最新のユーザーメッセージ: '{message}'") + logger.info(f"📚 会話履歴件数: {len(history)}") + + # 履歴をテキスト形式で確認 + if history: + history_text = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in history]) + logger.info(f"📜 履歴テキスト: {history_text}") + else: + logger.info("📜 履歴テキスト: 空") + + # 現在のメッセージも含めてキーワードチェック + full_text = message + "\n" + "\n".join([f"{u} {m}" for u, m in history]) + logger.info(f"🔍 キーワード検索対象テキスト: '{full_text}'") + + # Groq APIクライアントの状態確認 + if hasattr(managers['scene_manager'], 'groq_client') and managers['scene_manager'].groq_client: + logger.info("✅ Groq APIクライアント利用可能") + else: + logger.warning("⚠️ Groq APIクライアント利用不可 - フォールバックモードのみ") + + # 強制的にシーン変更検知を実行(履歴に現在のメッセージを含める) + extended_history = history + [(message, "")] # 現在のメッセージも含める + new_theme = managers['scene_manager'].detect_scene_change(extended_history, current_theme=current_theme) + logger.info(f"🎯 シーン変更検知結果: {new_theme}") + + instruction = None + if new_theme: + logger.info(f"🎬 シーン変更検出! '{current_theme}' → '{new_theme}'") + st.session_state.chat['scene_params'] = managers['scene_manager'].update_scene_params(st.session_state.chat['scene_params'], new_theme) + instruction = managers['scene_manager'].get_scene_transition_message(current_theme, new_theme) + st.session_state.scene_change_flag = True + + # デバッグモード時にユーザーに通知 + if st.session_state.get("debug_mode", False): + st.success(f"🎬 シーン変更: {current_theme} → {new_theme}") + else: + logger.info(f"シーン変更なし - 現在のテーマを維持: {current_theme}") + # デバッグモード時にシーン変更なしの理由を表示 + if st.session_state.get("debug_mode", False): + st.info(f"🎬 シーン変更なし: {current_theme} を維持") + + # シーン変更時に背景を更新 + try: + logger.info(f"シーン変更時の背景更新: テーマ '{new_theme}' を適用します") + logger.info(f"update_background関数を呼び出します...") + update_background(managers['scene_manager'], new_theme) + logger.info(f"update_background関数の呼び出しが完了しました") + except Exception as e: + logger.error(f"シーン変更時の背景更新でエラーが発生: {e}") + import traceback + logger.error(f"シーン変更時の背景更新エラーの詳細: {traceback.format_exc()}") + # エラーが発生してもアプリケーションは継続 + + # メモリ圧縮とサマリー取得(初期メッセージを除外) + non_initial_messages = [msg for msg in st.session_state.chat['messages'] + if not msg.get('is_initial', False)] + compressed_messages, important_words = st.session_state.memory_manager.compress_history( + non_initial_messages + ) + memory_summary = st.session_state.memory_manager.get_memory_summary() + + # デバッグ: メモリサマリーの内容をログ出力 + if memory_summary: + logger.warning(f"🧠 メモリサマリーが存在: {memory_summary[:100]}...") + else: + logger.info("🧠 メモリサマリーは空です(初対面状態)") + + # 対話生成(隠された真実機能統合済み) + response = managers['dialogue_generator'].generate_dialogue( + history, message, affection, stage_name, st.session_state.chat['scene_params'], instruction, memory_summary, st.session_state.chat['ura_mode'] + ) + + # デバッグ: AI応答の形式をチェック + if response: + logger.info(f"🤖 AI応答: '{response[:100]}...'") + if '[HIDDEN:' in response: + logger.info("✅ HIDDEN形式を検出") + else: + logger.warning("⚠️ HIDDEN形式が見つからない - フォールバック処理を実行") + # HIDDEN形式でない場合は、強制的にHIDDEN形式に変換 + response = f"[HIDDEN:(本当の気持ちは...)]{response}" + logger.info(f"🔧 フォールバック後: '{response[:100]}...'") + + return response if response else "[HIDDEN:(言葉が出てこない...)]…なんて言えばいいか分からない。" + + except Exception as e: + # ★★★ ここからがデバッグ用のコード ★★★ + import traceback + + # ターミナルに強制的にエラーの詳細を出力する + print("--- !!! PROCESS_CHAT_MESSAGE CRASHED !!! ---") + print(f"ERROR TYPE: {type(e)}") + print(f"ERROR DETAILS: {e}") + traceback.print_exc() # これが最も重要な行! + print("---------------------------------------------") + + # Streamlitのログにも詳細を記録する + logger.error(f"チャットメッセージ処理で致命的なエラーが発生", exc_info=True) + + # 画面にはエラーメッセージを返す + return "(ごめん、システムの内部で深刻なエラーが起きたみたい。ターミナルを確認して。)" + + # 初期化(セッションステートに必要な変数を登録) + if "awaiting_response" not in st.session_state: + st.session_state.awaiting_response = False + if "latest_prompt" not in st.session_state: + st.session_state.latest_prompt = "" + if "chat" not in st.session_state: + st.session_state.chat = {} + if "messages" not in st.session_state.chat: + st.session_state.chat['messages'] = [] + if "affection_notifications" not in st.session_state: + st.session_state.affection_notifications = [] + # ユーザー入力受付(送信時に一度だけ rerun が起きる) + user_input = st.chat_input("麻理に話しかける...") + if user_input and not st.session_state.awaiting_response: + st.session_state.latest_prompt = user_input + st.session_state.awaiting_response = True + + # タブ増殖防止:rerun前にチャット再レンダリングフラグを設定 + st.session_state.force_chat_rerender = True + + # rerun回数を制限(無限ループ防止) + rerun_count = st.session_state.get('rerun_count', 0) + if rerun_count < 10: # 最大10回まで + st.session_state.rerun_count = rerun_count + 1 + logger.info(f"メッセージ送信によるrerun実行 (回数: {st.session_state.rerun_count})") + st.rerun() + else: + logger.warning("rerun回数制限に達しました。処理を継続します。") + st.session_state.rerun_count = 0 + + # 応答処理(rerun 後にこのブロックが実行される) + if st.session_state.awaiting_response: + prompt = st.session_state.latest_prompt + + # 1. 入力チェック + if len(prompt) > MAX_INPUT_LENGTH: + st.error(f"⚠️ メッセージは{MAX_INPUT_LENGTH}文字以内で入力してください。") + st.session_state.awaiting_response = False + st.session_state.latest_prompt = "" + else: + # 2. ユーザーのメッセージを表示に追加 + user_message_id = f"user_{len(st.session_state.chat['messages'])}" + managers['chat_interface'].add_message("user", prompt, st.session_state.chat['messages'], user_message_id) + + # 3. AI応答生成(スピナー付き) + with cute_thinking_spinner(): + response = process_chat_message(prompt) + + # 4. AI応答を履歴に追加 + assistant_message_id = f"assistant_{len(st.session_state.chat['messages'])}" + managers['chat_interface'].add_message("assistant", response, st.session_state.chat['messages'], assistant_message_id) + + # 5. シーン変更フラグがあればリセット + if st.session_state.get('scene_change_flag', False): + del st.session_state['scene_change_flag'] + + # 6. 応答完了状態に戻す + st.session_state.awaiting_response = False + st.session_state.latest_prompt = "" + + # rerun回数をリセット + st.session_state.rerun_count = 0 + + # エラーレスポンス以外の場合のみrerunを実行(タブ増殖防止) + if "システムに問題" not in response and "内部で深刻なエラー" not in response: + # チャット再レンダリングフラグを設定してからrerun + st.session_state.force_chat_rerender = True + logger.info("AI応答完了によるrerun実行") + st.rerun() + # DogAssistantの固定配置コンポーネントを描画(右下のみ) + # 犬のボタン表示前にチャットセッション状態を確認 + if 'chat' not in st.session_state: + logger.warning("犬のボタン表示前にチャットセッションが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat = { + "messages": [initial_message], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": managers["rate_limiter"].create_limiter_state(), + "scene_change_pending": None, + "ura_mode": False + } + logger.info("犬のボタン表示前にチャットセッションを初期化しました") + elif 'messages' not in st.session_state.chat: + logger.warning("犬のボタン表示前にメッセージリストが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + logger.info("犬のボタン表示前にメッセージリストを初期化しました") + elif not any(msg.get('is_initial', False) for msg in st.session_state.chat['messages']): + logger.warning("犬のボタン表示前に初期メッセージが見つかりません - 復元します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("犬のボタン表示前に初期メッセージを復元しました") + + # DogAssistantコンポーネント(テスト中) + try: + managers['dog_assistant'].render_dog_component(tutorial_manager) + except Exception as e: + logger.error(f"DogAssistant描画エラー: {e}") + # フォールバック: シンプルなボタンを表示 + if st.button("🐕 ポチ(本音表示)", key="simple_dog_button", help="麻理の本音を表示/非表示"): + current_state = st.session_state.get('show_all_hidden', False) + st.session_state.show_all_hidden = not current_state + st.session_state.show_all_hidden_changed = True + logger.info(f"🐕 シンプル犬ボタン: {not current_state}") + st.rerun() + +# === 手紙タブの描画関数 === +async def generate_tutorial_letter_async(theme: str, managers) -> str: + """チュートリアル用の短縮版手紙を非同期で生成する(Groq + Qwen使用)""" + try: + # 現在の好感度と関係性を取得 + current_affection = st.session_state.chat.get('affection', 30) + stage_name = managers['sentiment_analyzer'].get_relationship_stage(current_affection) + user_id = st.session_state.user_id + + # チュートリアル用のユーザー履歴を構築 + tutorial_user_history = { + 'profile': { + 'total_letters': 0, + 'affection': current_affection, + 'stage': stage_name + }, + 'letters': {} + } + + # 手紙生成器を使用(Groq + Qwen の2段階プロセス) + letter_generator = managers.get('letter_generator') + if not letter_generator: + # フォールバック:直接生成 + return await generate_tutorial_letter_fallback(theme, current_affection, stage_name) + + # 通常の手紙生成システムを使用 + letter_result = await letter_generator.generate_letter(user_id, theme, tutorial_user_history) + + # 600文字以内に制限 + letter_content = letter_result['content'] + if len(letter_content) > 600: + # 文の区切りで切り詰める + sentences = letter_content.split('。') + truncated_content = "" + for sentence in sentences: + if len(truncated_content + sentence + '。') <= 600: + truncated_content += sentence + '。' + else: + break + letter_content = truncated_content.rstrip('。') + '……' + + return letter_content + + except Exception as e: + logger.error(f"チュートリアル手紙生成エラー: {e}") + # フォールバック手紙 + return await generate_tutorial_letter_fallback(theme, current_affection, stage_name) + +async def generate_tutorial_letter_fallback(theme: str, current_affection: int, stage_name: str) -> str: + """チュートリアル手紙生成のフォールバック""" + return f"""いつもありがとう。 + +{theme}のこと……書こうと思ったんだけど、なんか恥ずかしくて。 +あんたと話してると、いつもと違う自分になれる気がするの。 +それって、きっと特別なことよね。 + +私、まだまだ素直になれないけれど…… +少しずつ、あんたのことを知りたいと思ってる。 + +……ま、忘れて。バカじゃないの。 +でも、ありがとう。""" + +def generate_tutorial_letter(theme: str, managers) -> str: + """チュートリアル用手紙生成の同期ラッパー""" + logger.info(f"📝 generate_tutorial_letter開始: theme='{theme}'") + try: + result = run_async(generate_tutorial_letter_async(theme, managers)) + logger.info(f"📝 generate_tutorial_letter成功: 文字数={len(result) if result else 0}") + return result + except Exception as e: + logger.error(f"チュートリアル手紙生成同期ラッパーエラー: {e}") + current_affection = st.session_state.chat.get('affection', 30) + stage_name = managers['sentiment_analyzer'].get_relationship_stage(current_affection) + logger.info(f"📝 フォールバック手紙生成開始") + result = run_async(generate_tutorial_letter_fallback(theme, current_affection, stage_name)) + logger.info(f"📝 フォールバック手紙生成完了: 文字数={len(result) if result else 0}") + return result + +def render_letter_tab(managers): + """「手紙を受け取る」タブのUIを描画する""" + st.title("✉️ おやすみ前の、一通の手紙") + + # タブ切り替え検出とフォーム状態のリセット + current_tab = "letter" + last_active_tab = st.session_state.get('last_active_tab', '') + + # 初回訪問時の処理 + if last_active_tab == '': + logger.info("📝 手紙タブ初回訪問") + + # 他のタブから手紙タブに切り替わった場合の処理(rerunなし) + elif last_active_tab != current_tab and last_active_tab not in ['', 'letter']: + # フォーム関連の状態をリセット(ただし手紙生成完了状態は保持) + logger.info(f"🔄 タブ切り替え検出: {last_active_tab} → {current_tab}") + + # フォーム送信状態もリセット(新しいキー形式に対応) + form_keys_to_clear = [key for key in st.session_state.keys() if 'letter_form' in key or 'letter_request_form' in key] + for key in form_keys_to_clear: + del st.session_state[key] + logger.info(f"フォームキーをクリア: {key}") + + # 手紙表示関連のセッション状態もクリア + if 'letter_reflected' in st.session_state: + del st.session_state.letter_reflected + + # 手紙フォーム関連の状態もクリア + if 'letter_form_submitted' in st.session_state: + del st.session_state.letter_form_submitted + if 'letter_form_theme' in st.session_state: + del st.session_state.letter_form_theme + if 'letter_form_generation_hour' in st.session_state: + del st.session_state.letter_form_generation_hour + if 'letter_form_is_instant' in st.session_state: + del st.session_state.letter_form_is_instant + + # ユーザーデータキャッシュをリフレッシュ + st.session_state.force_refresh_user_data = True + + # 現在のタブを記録 + st.session_state.last_active_tab = current_tab + + # チュートリアル案内(ステップ4の場合のみ) + tutorial_manager = managers.get('tutorial_manager') + if tutorial_manager and tutorial_manager.get_current_step() == 4: + # 手紙タブに到達したことを祝福 + st.success("🎉 素晴らしい!手紙タブに到達しました!") + # 手紙タブ専用の案内を表示 + st.info("📝 下のフォームから手紙をリクエストしてみてください。手紙をリクエストするとステップ4が完了します。") + st.write("今日の終わりに、あなたのためだけにAIが手紙を綴ります。伝えたいテーマと時間を選ぶと、あなたがログインした時に手紙が届きます。") + + # 手紙機能のチュートリアル + col1, col2, col3 = st.columns([1, 2, 1]) + with col2: + with st.expander("📝 手紙機能の使い方", expanded=False): + st.markdown(""" + ### ✉️ 手紙機能について + 麻理があなたのために、心を込めて手紙を書いてくれる特別な機能です。 + + ### 📅 利用方法 + 1. **好感度を上げる**:手紙をリクエストするには好感度40以上が必要です + 2. **テーマを入力**:手紙に書いてほしい内容やテーマを入力 + 3. **時間を選択**:手紙を書いてほしい深夜の時間を選択 + 4. **リクエスト送信**:「この内容でお願いする」ボタンを押す + 5. **手紙を受け取り**:指定した時間に手紙が生成されます + 6. **会話に反映**:手紙を読んだ後、「会話に反映」ボタンで麻理との会話で話題にできます + + ### 💡 テーマの例 + - 「今日見た美しい夕日について」 + - 「最近読んだ本の感想」 + - 「季節の変わり目の気持ち」 + - 「大切な人への想い」 + - 「将来への希望や不安」 + + ### ⏰ 生成時間 + - **深夜2時〜4時**の間で選択可能 + - 静かな夜の時間に、ゆっくりと手紙を綴ります + - **1日1通まで**リクエスト可能 + + ### 💝 利用条件 + - **好感度40以上**が必要です + - 麻理との会話を重ねて関係を深めてください + + ### 📖 手紙の確認 + - 生成された手紙は下の「あなたへの手紙」で確認できます + - 過去の手紙も保存されているので、いつでも読み返せます + + --- + **心に残るテーマを入力して、麻理からの特別な手紙を受け取ってみてください** 💌 + """) + + user_id = st.session_state.user_id + user_manager = managers['user_manager'] + request_manager = managers['request_manager'] + + st.divider() + + # --- 手紙のリクエストフォーム --- + st.subheader("新しい手紙をリクエストする") + + # 現在の好感度を取得 + current_affection = st.session_state.chat['affection'] + required_affection = 40 + + # 手紙生成回数を取得して即時生成可能かチェック(キャッシュ機能付き) + cache_key = f"user_data_{user_id}" + + # セッション内でのキャッシュをチェック(データベースアクセスを最小化) + if cache_key in st.session_state and not st.session_state.get('force_refresh_user_data', False): + user_data = st.session_state[cache_key] + logger.info(f"📋 キャッシュからユーザーデータを取得: {user_id[:8]}...") + else: + try: + user_data = run_async(user_manager.storage.get_user_data(user_id)) + st.session_state[cache_key] = user_data # キャッシュに保存 + st.session_state.force_refresh_user_data = False # フラグをリセット + logger.info(f"📋 データベースからユーザーデータを取得: {user_id[:8]}...") + except Exception as e: + logger.error(f"ユーザーデータ取得エラー: {e}") + user_data = {"letters": {}} # エラー時のフォールバック + + letters_generated = len(user_data.get("letters", {})) + can_instant_generate = letters_generated == 0 # 初回のみ即時生成可能 + + # デバッグ情報(簡潔版) + logger.info(f"📊 ユーザー {user_id[:8]}... の手紙生成回数: {letters_generated}, 初回判定: {can_instant_generate}") + + # デバッグモードの場合のみ詳細表示 + if st.session_state.get('debug_mode', False): + st.info(f"🔍 デバッグ: ユーザーID={user_id[:8]}..., 手紙数={letters_generated}, 初回={can_instant_generate}") + st.info(f"🔍 データベースパス: {user_manager.storage.file_path}") + if user_data.get("letters"): + st.info(f"🔍 既存の手紙: {list(user_data['letters'].keys())}") + else: + st.info("🔍 このユーザーには手紙データがありません(初回ユーザー)") + + + # 手紙リクエスト可能かどうかの判定 + can_request_letter = current_affection >= required_affection or can_instant_generate + + # リクエスト状況をチェック + try: + request_status = run_async(request_manager.get_user_request_status(user_id)) + except Exception as e: + logger.error(f"リクエスト状況取得エラー: {e}") + request_status = {"has_request": False} + + # 既にリクエスト済みの場合 + if request_status.get("has_request"): + status = request_status.get('status', 'unknown') + hour = request_status.get('generation_hour') + if status == 'pending': + st.info(f"本日分のリクエストは受付済みです。深夜{hour}時頃に手紙が生成されます。") + else: + st.success("本日分の手紙は処理済みです。下記の一覧からご確認ください。") + can_request_letter = False # 既にリクエスト済みの場合は新規リクエスト不可 + + # 好感度不足の場合の警告(履歴は表示するためreturnしない) + elif not can_request_letter: + st.warning(f"💔 手紙をリクエストするには好感度が{required_affection}以上必要です。現在の好感度: {current_affection}") + st.info("麻理ともっと会話して、関係を深めてから手紙をお願いしてみてください。") + st.info("💡 過去の手紙は下の履歴から確認できます。") + + # 初回の特別案内 + elif can_instant_generate: + st.info("📘 **初回特典**: 初回のみ好感度に関係なく手紙をリクエストできます!") + + # 📖 **手紙生成完了後の読む画面(フォーム非表示)** + if st.session_state.get('letter_generation_completed', False): + st.success("✉️ 手紙の生成が完了しました!") + + # 生成された手紙を表示 + if 'generated_letter_content' in st.session_state: + with st.expander("📝 生成された手紙", expanded=True): + st.markdown(st.session_state.generated_letter_content.replace("\n", "\n\n")) + + st.info("📖 生成された手紙は下の履歴からも確認できます。") + st.info("💡 新しい手紙をリクエストしたい場合は、下のボタンを押してください。") + + col1, col2, col3 = st.columns([1, 2, 1]) + with col2: + if st.button("📮 新しい手紙をリクエストする", type="primary", use_container_width=True): + # 手紙生成完了状態をクリア + st.session_state.letter_generation_completed = False + + # 生成された手紙データをクリア + if 'generated_letter_content' in st.session_state: + del st.session_state.generated_letter_content + if 'generated_letter_theme' in st.session_state: + del st.session_state.generated_letter_theme + + # フォーム状態をクリア + if 'letter_form_submitted' in st.session_state: + del st.session_state.letter_form_submitted + if 'letter_form_theme' in st.session_state: + del st.session_state.letter_form_theme + if 'letter_form_generation_hour' in st.session_state: + del st.session_state.letter_form_generation_hour + if 'letter_form_is_instant' in st.session_state: + del st.session_state.letter_form_is_instant + + # ユーザーデータキャッシュをリフレッシュ + st.session_state.force_refresh_user_data = True + + logger.info("📮 新しい手紙リクエストボタンが押されました") + st.success("✅ 新しい手紙をリクエストできます!") + # 次回レンダリング時に自動で反映される(st.rerun()削除) + + st.divider() + + # 手紙リクエストフォーム(生成完了後は非表示) + elif can_request_letter: + # 好感度が十分な場合または初回の場合のフォーム表示 + if not can_instant_generate: + st.success(f"💝 好感度{current_affection}で手紙をリクエストできます!") + + + + # 📝 **条件分岐をタブレベルで実装(フォーム内は分岐なし)** + + # 条件分岐による表示切り替え(フォーム外で実行) + if can_instant_generate: + # 初回ユーザー向けタブ + st.success("🎉 **初回特典**: 好感度に関係なく手紙をリクエストできます!") + + # 初回用フォーム(分岐なし・固定構造) + with st.form("instant_letter_form"): + st.write("💌 **初回手紙生成**") + + theme = st.text_input( + "手紙に書いてほしいテーマ", + placeholder="例: 今日見た美しい夕日について", + help="麻理に書いてほしい内容やテーマを入力してください" + ) + + st.info("📘 初回は即座に手紙を生成します(時間選択不要)") + + submitted = st.form_submit_button("💌 初回手紙を生成する", type="primary") + + if submitted: + st.session_state.letter_form_submitted = True + st.session_state.letter_form_theme = theme + st.session_state.letter_form_generation_hour = None + st.session_state.letter_form_is_instant = True + st.success("✅ 初回手紙リクエストを受付しました!") + + else: + # 通常ユーザー向けタブ + st.success(f"💝 好感度{current_affection}で手紙をリクエストできます!") + + # 通常用フォーム(分岐なし・固定構造) + with st.form("scheduled_letter_form"): + st.write("📮 **手紙のリクエスト**") + + theme = st.text_input( + "手紙に書いてほしいテーマ", + placeholder="例: 今日見た美しい夕日について", + help="麻理に書いてほしい内容やテーマを入力してください" + ) + + generation_hour = st.selectbox( + "手紙を書いてほしい時間", + options=[2, 3, 4], + format_func=lambda x: f"深夜{x}時頃", + help="静かな夜の時間に、ゆっくりと手紙を綴ります" + ) + + submitted = st.form_submit_button("📮 この内容でお願いする", type="primary") + + if submitted: + st.session_state.letter_form_submitted = True + st.session_state.letter_form_theme = theme + st.session_state.letter_form_generation_hour = generation_hour + st.session_state.letter_form_is_instant = False + st.success("✅ 手紙リクエストを受付しました!") + + # 📋 **統一的なフォーム処理(条件分岐なし)** + + # セッション状態から統一的に取得 + form_submitted = st.session_state.get('letter_form_submitted', False) + form_theme = st.session_state.get('letter_form_theme', '') + form_generation_hour = st.session_state.get('letter_form_generation_hour', None) + form_is_instant = st.session_state.get('letter_form_is_instant', False) + + # 処理完了チェックは上部の条件分岐で既に処理済み + + # 統一的な処理ロジック + if form_submitted: + + # テーマ検証 + if not form_theme: + st.error("❌ テーマを入力してください。") + else: + st.success(f"✅ テーマ検証OK: '{form_theme}'") + + # データベース状態確認 + try: + current_user_data = run_async(user_manager.storage.get_user_data(user_id)) + current_letters_count = len(current_user_data.get("letters", {})) + is_database_first_time = current_letters_count == 0 + + st.info(f"📊 データベース状態: 手紙数={current_letters_count}, DB初回判定={is_database_first_time}") + + # 処理タイプの決定(フォームの判定とDBの判定を照合) + if form_is_instant and is_database_first_time: + # 初回手紙生成(自動実行) + st.info("🚀 **初回手紙生成を実行中...**") + + with st.spinner("初回手紙を生成中..."): + try: + # 手紙生成 + instant_letter = generate_tutorial_letter(form_theme, managers) + + # データベースに保存 + from datetime import datetime + current_date = datetime.now().strftime("%Y-%m-%d") + + if "letters" not in current_user_data: + current_user_data["letters"] = {} + + current_user_data["letters"][current_date] = { + "theme": form_theme, + "content": instant_letter, + "status": "completed", + "generation_hour": "instant", + "created_at": datetime.now().isoformat(), + "type": "instant" + } + + # プロフィール更新 + if "profile" not in current_user_data: + current_user_data["profile"] = {} + current_user_data["profile"]["total_letters"] = len(current_user_data["letters"]) + current_user_data["profile"]["last_letter_date"] = current_date + + # 保存 + run_async(user_manager.storage.update_user_data(user_id, current_user_data)) + + # 成功表示 + st.success("✉️ 麻理からの手紙が届きました!") + + # 手紙表示 + with st.expander("📝 生成された手紙", expanded=True): + st.markdown(instant_letter.replace("\n", "\n\n")) + + # チュートリアル完了 + tutorial_manager = managers.get('tutorial_manager') + if tutorial_manager and tutorial_manager.get_current_step() <= 4: + tutorial_manager.complete_step(4) + st.balloons() + st.success("🎉 チュートリアルステップ4完了!") + + # 生成された手紙をセッション状態に保存(rerun後も表示するため) + st.session_state.generated_letter_content = instant_letter + st.session_state.generated_letter_theme = form_theme + + # 処理完了フラグを設定 + st.session_state.letter_generation_completed = True + + # セッション状態クリア + st.session_state.letter_form_submitted = False + for key in ['letter_form_theme', 'letter_form_generation_hour', 'letter_form_is_instant']: + if key in st.session_state: + del st.session_state[key] + + # キャッシュ更新 + cache_key = f"user_data_{user_id}" + st.session_state[cache_key] = current_user_data + + st.info("💡 手紙は履歴に保存されました。下の履歴から確認できます。") + + # 画面更新は次回レンダリング時に自動で反映される(st.rerun()削除) + + except Exception as e: + st.error(f"❌ 手紙生成エラー: {e}") + import traceback + st.code(traceback.format_exc()) + + elif not form_is_instant and form_generation_hour: + # 通常の手紙リクエスト(自動実行) + st.info("📮 **通常の手紙リクエストを実行中...**") + + with st.spinner("リクエストを送信中..."): + try: + success, message = run_async( + request_manager.submit_request( + user_id, form_theme, form_generation_hour, affection=current_affection + ) + ) + + if success: + st.success(message) + st.info("💡 手紙は指定した時間に生成されます。履歴から確認してください。") + + # セッション状態クリア(st.rerun()は使わない) + st.session_state.letter_form_submitted = False + for key in ['letter_form_theme', 'letter_form_generation_hour', 'letter_form_is_instant']: + if key in st.session_state: + del st.session_state[key] + + # st.rerun() を削除 - セッション状態の変更で次回レンダリング時に反映される + else: + st.error(message) + + except Exception as e: + st.error(f"❌ リクエスト送信エラー: {e}") + import traceback + st.code(traceback.format_exc()) + + else: + # 不整合状態 + st.warning("⚠️ フォーム状態とデータベース状態に不整合があります") + st.write(f"フォーム即時判定: {form_is_instant}, DB初回判定: {is_database_first_time}") + + if st.button("🔄 状態をリセット", key="reset_inconsistent_state"): + st.session_state.letter_form_submitted = False + for key in ['letter_form_theme', 'letter_form_generation_hour', 'letter_form_is_instant']: + if key in st.session_state: + del st.session_state[key] + st.success("✅ 状態をリセットしました。次回アクセス時に反映されます。") + + except Exception as e: + st.error(f"❌ データベースアクセスエラー: {e}") + import traceback + st.code(traceback.format_exc()) + + + # 好感度に関係なく履歴は常に表示 + st.divider() + + # --- 過去の手紙一覧 --- + st.subheader("あなたへの手紙") + with st.spinner("手紙の履歴を読み込んでいます..."): + try: + history = run_async(user_manager.get_user_letter_history(user_id, limit=10)) + except Exception as e: + logger.error(f"手紙履歴取得エラー: {e}") + history = [] + + if not history: + st.info("まだ手紙はありません。最初の手紙をリクエストしてみましょう。") + else: + for letter_info in history: + date = letter_info.get("date") + theme = letter_info.get("theme") + status = letter_info.get("status", "unknown") + + with st.expander(f"{date} - テーマ: {theme} ({status})"): + if status == "completed": + try: + user_data = run_async(user_manager.storage.get_user_data(user_id)) + content = user_data.get("letters", {}).get(date, {}).get("content", "内容の取得に失敗しました。") + st.markdown(content.replace("\n", "\n\n")) + + # 手紙を会話に反映するボタン + col1, col2 = st.columns([3, 1]) + with col2: + if st.button(f"💬 会話に反映", key=f"reflect_{date}", help="この手紙の内容を麻理との会話で話題にします"): + # 手紙の内容をメモリに追加 + letter_summary = f"手紙のテーマ「{theme}」について麻理が書いた内容: {content[:200]}..." + memory_notification = st.session_state.memory_manager.add_important_memory("letter_content", letter_summary) + + # 手紙について話すメッセージを自動追加 + letter_message = f"この前書いてくれた「{theme}」についての手紙、読ませてもらったよ。" + st.session_state.chat['messages'].append({"role": "user", "content": letter_message}) + + # 麻理の応答を生成 + response = f"あの手紙、読んでくれたんだ...。「{theme}」について書いたとき、あなたのことを思いながら一生懸命考えたんだ。どう思った?" + st.session_state.chat['messages'].append({"role": "assistant", "content": response}) + + # 特別な記憶の通知をセッション状態に保存 + st.session_state.memory_notifications.append(memory_notification) + st.success("手紙の内容が会話に反映されました!チャットタブで確認してください。") + st.rerun() + + except Exception as e: + logger.error(f"手紙内容取得エラー: {e}") + st.error("手紙の内容を読み込めませんでした。") + elif status == "pending": + st.write("この手紙はまだ生成中です。") + else: + st.write("この手紙は生成に失敗しました。") + + +# --- ▼▼▼ 3. メイン実行ブロック ▼▼▼ --- + +def main(): + """メイン関数""" + st.set_page_config( + page_title="麻理プロジェクト", page_icon="🤖", + layout="centered", initial_sidebar_state="auto" + ) + + # event loopの安全な設定 + try: + # 既存のループがあるかチェック + asyncio.get_running_loop() + except RuntimeError: + # 実行中のループがない場合のみ新しいループを設定 + if sys.platform.startswith('win'): + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + # rerun検出機能(簡素化版) + current_run_id = id(st.session_state) + last_run_id = st.session_state.get('last_run_id', None) + + if last_run_id != current_run_id: + logger.info(f"Rerun検出: 前回={last_run_id}, 今回={current_run_id}") + st.session_state.last_run_id = current_run_id + + # 全ての依存モジュールを初期化 + managers = initialize_all_managers() + + # セッションステートを初期化 + initialize_session_state(managers) + + # CSSを適用 + inject_custom_css() + + # CSS読み込み完了後に背景を更新(初期設定) + try: + initial_theme = st.session_state.chat['scene_params'].get('theme', 'default') + logger.info(f"初期背景設定: テーマ '{initial_theme}' を適用します") + logger.info(f"update_background関数を呼び出します...") + + # 常に背景を更新(CSS読み込み待ちなし) + update_background(managers['scene_manager'], initial_theme) + logger.info(f"初期背景設定完了") + + except Exception as e: + logger.error(f"初期背景設定でエラーが発生: {e}") + import traceback + logger.error(f"初期背景設定エラーの詳細: {traceback.format_exc()}") + # エラーが発生してもアプリケーションは継続 + + # チュートリアル機能の初期化 + tutorial_manager = managers['tutorial_manager'] + + # 初回訪問時のウェルカムダイアログ + if tutorial_manager.should_show_tutorial(): + # チュートリアルダイアログ表示前にチャットセッション状態を保護 + if 'chat' not in st.session_state: + logger.warning("チュートリアルダイアログ表示前にチャットセッションが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat = { + "messages": [initial_message], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": managers["rate_limiter"].create_limiter_state(), + "scene_change_pending": None, + "ura_mode": False + } + logger.info("チュートリアルダイアログ表示前にチャットセッションを初期化しました") + elif 'messages' not in st.session_state.chat: + logger.warning("チュートリアルダイアログ表示前にメッセージリストが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + logger.info("チュートリアルダイアログ表示前にメッセージリストを初期化しました") + elif not any(msg.get('is_initial', False) for msg in st.session_state.chat['messages']): + logger.warning("チュートリアルダイアログ表示前に初期メッセージが見つかりません - 復元します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("チュートリアルダイアログ表示前に初期メッセージを復元しました") + + # チュートリアル表示中フラグを設定 + st.session_state.tutorial_dialog_showing = True + + tutorial_manager.render_welcome_dialog() + tutorial_manager.mark_tutorial_shown() + + # チュートリアル表示完了フラグをクリア + st.session_state.tutorial_dialog_showing = False + + # チュートリアル開始/スキップの処理 + if st.session_state.get('tutorial_start_requested', False): + st.session_state.tutorial_start_requested = False + + # チュートリアル処理中フラグを設定 + st.session_state.tutorial_processing = True + + # チャットセッション状態を確実に初期化 + if 'chat' not in st.session_state: + logger.warning("チュートリアル開始時にチャットセッションが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat = { + "messages": [initial_message], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": managers["rate_limiter"].create_limiter_state(), + "scene_change_pending": None, + "ura_mode": False + } + logger.info("チュートリアル開始時にチャットセッションを初期化しました") + elif 'messages' not in st.session_state.chat: + logger.warning("チュートリアル開始時にメッセージリストが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + logger.info("チュートリアル開始時にメッセージリストを初期化しました") + else: + # 初期メッセージを確実に保護 + messages = st.session_state.chat['messages'] + if not any(msg.get('is_initial', False) for msg in messages): + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("チュートリアル開始時に初期メッセージを復元しました") + + # チュートリアル処理完了フラグをクリア(チャット履歴表示を再開) + st.session_state.tutorial_processing = False + + # チュートリアル開始の案内 + st.success("📘 チュートリアルを開始しました!青い案内ボックスに従って進めてください。") + + if st.session_state.get('tutorial_skip_requested', False): + st.session_state.tutorial_skip_requested = False + + # チュートリアル処理中フラグを設定 + st.session_state.tutorial_processing = True + + # チャットセッション状態を確実に初期化 + if 'chat' not in st.session_state: + logger.warning("チュートリアルスキップ時にチャットセッションが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat = { + "messages": [initial_message], + "affection": 30, + "scene_params": {"theme": "default"}, + "limiter_state": managers["rate_limiter"].create_limiter_state(), + "scene_change_pending": None, + "ura_mode": False + } + logger.info("チュートリアルスキップ時にチャットセッションを初期化しました") + elif 'messages' not in st.session_state.chat: + logger.warning("チュートリアルスキップ時にメッセージリストが存在しません - 初期化します") + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'] = [initial_message] + logger.info("チュートリアルスキップ時にメッセージリストを初期化しました") + else: + # 初期メッセージを確実に保護 + messages = st.session_state.chat['messages'] + if not any(msg.get('is_initial', False) for msg in messages): + initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True} + st.session_state.chat['messages'].insert(0, initial_message) + logger.info("チュートリアルスキップ時に初期メッセージを復元しました") + + # チュートリアル処理完了フラグをクリア(チャット履歴表示を再開) + st.session_state.tutorial_processing = False + + st.success("⏭️ チュートリアルをスキップしました。麻理との会話をお楽しみください!") + + # チュートリアルステップ4の場合、手紙タブを強調表示 + tutorial_manager = managers['tutorial_manager'] + current_step = tutorial_manager.get_current_step() + + # タブ名を動的に設定 + if current_step == 4 and not tutorial_manager.is_step_completed(4): + # ステップ4の場合、手紙タブを強調 + letter_tab_name = "👉 ✉️ 手紙を受け取る ← ここをクリック!" + + # 手紙タブ強調のCSS + tab_highlight_css = """ + + """ + + st.markdown(tab_highlight_css, unsafe_allow_html=True) + + # 矢印の表示 + arrow_html = """ +
+ ↓ 手紙タブをクリック! ↓ +
+ """ + + st.markdown(arrow_html, unsafe_allow_html=True) + else: + letter_tab_name = "✉️ 手紙を受け取る" + + # タブ増殖防止:タブ作成の重複実行を制御 + if 'tabs_created' not in st.session_state: + st.session_state.tabs_created = False + st.session_state.tab_creation_count = 0 + + # タブ作成の重複を防止(セッション内で1回のみ作成) + tab_names = ["💬 麻理と話す", letter_tab_name, "📘 チュートリアル"] + + # タブ名が変更された場合のみ再作成を許可 + current_tab_names_hash = hash(str(tab_names)) + last_tab_names_hash = st.session_state.get('last_tab_names_hash', None) + + if last_tab_names_hash != current_tab_names_hash: + logger.info(f"タブ名変更検出:タブを再作成します (ハッシュ: {last_tab_names_hash} -> {current_tab_names_hash})") + st.session_state.tabs_created = False + st.session_state.last_tab_names_hash = current_tab_names_hash + + # タブを作成(重複防止機能付き) + if not st.session_state.tabs_created: + st.session_state.tab_creation_count += 1 + logger.info(f"タブを作成します (作成回数: {st.session_state.tab_creation_count})") + + # タブ作成前にチャット再レンダリングフラグを設定 + st.session_state.force_chat_rerender = True + + chat_tab, letter_tab, tutorial_tab = st.tabs(tab_names) + st.session_state.tabs_created = True + + logger.info("タブ作成完了") + else: + # 既存のタブを再利用(実際にはStreamlitが内部で管理) + logger.debug("既存タブを再利用") + chat_tab, letter_tab, tutorial_tab = st.tabs(tab_names) + + with chat_tab: + # 現在のタブを記録(タブ切り替え検出用) + if st.session_state.get('last_active_tab') != "chat": + st.session_state.last_active_tab = "chat" + logger.debug("チャットタブがアクティブになりました") + + render_chat_tab(managers) + + with letter_tab: + # 現在のタブを記録(タブ切り替え検出用) + if st.session_state.get('last_active_tab') != "letter": + st.session_state.last_active_tab = "letter" + logger.debug("手紙タブがアクティブになりました") + + render_letter_tab(managers) + + with tutorial_tab: + # 現在のタブを記録(タブ切り替え検出用) + if st.session_state.get('last_active_tab') != "tutorial": + st.session_state.last_active_tab = "tutorial" + logger.debug("チュートリアルタブがアクティブになりました") + + tutorial_manager.render_tutorial_tab() + +if __name__ == "__main__": + if not Config.validate_config(): + logger.critical("手紙機能の設定にエラーがあります。アプリケーションを起動できません。") + else: + main() \ No newline at end of file diff --git a/maturi-yoru.jpg b/maturi-yoru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2cc54aca7a54321d93c06958363b4ec48f98d4be --- /dev/null +++ b/maturi-yoru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d8b93f8f5a1a642541ea901da0b9109d7661fcb9bb5a1588ff0122f68117d1b +size 2446319 diff --git a/persistent_user_manager.py b/persistent_user_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..07b5edf8f7089e5aa6dff97111fb02d2bcbfc1f0 --- /dev/null +++ b/persistent_user_manager.py @@ -0,0 +1,463 @@ +""" +Hugging Face Spaces永続ストレージ対応ユーザー管理システム +Cookieベースのユーザー識別と/mnt/dataでの状態永続化を提供 +""" +import os +import json +import uuid +import logging +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List +import streamlit as st +from streamlit_cookies_manager import EncryptedCookieManager + +logger = logging.getLogger(__name__) + +class PersistentUserManager: + """永続ストレージ対応ユーザー管理クラス""" + + def __init__(self, storage_base_path: str = "/mnt/data"): + """ + 初期化 + + Args: + storage_base_path: 永続ストレージのベースパス(HF Spacesでは/mnt/data) + """ + # Hugging Face Spacesの永続ストレージパス + self.storage_base_path = storage_base_path + self.user_data_dir = os.path.join(storage_base_path, "mari_users") + self.session_data_dir = os.path.join(storage_base_path, "mari_sessions") + + # Cookie管理設定 + self.cookie_name = "mari_user_id" + self.cookie_expiry_days = 30 + + # ディレクトリ作成 + self._ensure_directories() + + # Cookie管理の初期化 + self.cookies = self._init_cookie_manager() + + logger.info(f"永続ユーザー管理システム初期化: {self.user_data_dir}") + + def _ensure_directories(self): + """必要なディレクトリを作成""" + try: + os.makedirs(self.user_data_dir, exist_ok=True) + os.makedirs(self.session_data_dir, exist_ok=True) + logger.info(f"ストレージディレクトリ確認完了: {self.user_data_dir}") + except Exception as e: + logger.error(f"ディレクトリ作成エラー: {e}") + # フォールバック: ローカルディレクトリを使用 + self.user_data_dir = "local_mari_users" + self.session_data_dir = "local_mari_sessions" + os.makedirs(self.user_data_dir, exist_ok=True) + os.makedirs(self.session_data_dir, exist_ok=True) + logger.warning(f"フォールバック: ローカルディレクトリを使用 {self.user_data_dir}") + + def _init_cookie_manager(self) -> EncryptedCookieManager: + """Cookie管理システムを初期化""" + try: + # セキュアなパスワードを生成(環境変数から取得、なければ生成) + cookie_password = os.getenv("MARI_COOKIE_PASSWORD", "mari_chat_secure_key_2024") + + cookies = EncryptedCookieManager( + prefix="mari_", + password=cookie_password + ) + + # Cookieが準備できるまで待機 + if not cookies.ready(): + st.stop() + + logger.info("Cookie管理システム初期化完了") + return cookies + + except Exception as e: + logger.error(f"Cookie管理初期化エラー: {e}") + # フォールバック: セッション状態のみ使用 + return None + + def get_or_create_user_id(self) -> str: + """ + ユーザーIDを取得または新規作成(Cookie連携) + + Returns: + ユーザーID + """ + try: + # 1. CookieからユーザーIDを取得 + user_id = self._get_user_id_from_cookie() + + if user_id and self._is_valid_user_id(user_id): + # 有効なユーザーIDが存在 + self._update_user_access_time(user_id) + logger.info(f"既存ユーザーID使用: {user_id[:8]}...") + return user_id + + # 2. セッション状態から取得を試行 + user_id = st.session_state.get('persistent_user_id') + if user_id and self._is_valid_user_id(user_id): + # セッション状態にあるIDを使用してCookieを更新 + self._set_user_id_cookie(user_id) + self._update_user_access_time(user_id) + logger.info(f"セッション状態からユーザーID復元: {user_id[:8]}...") + return user_id + + # 3. 新規ユーザーIDを作成 + user_id = self._create_new_user() + logger.info(f"新規ユーザーID作成: {user_id[:8]}...") + return user_id + + except Exception as e: + logger.error(f"ユーザーID取得エラー: {e}") + # フォールバック: 一時的なIDを生成 + fallback_id = str(uuid.uuid4()) + st.session_state.persistent_user_id = fallback_id + return fallback_id + + def _get_user_id_from_cookie(self) -> Optional[str]: + """CookieからユーザーIDを取得""" + try: + if self.cookies is None: + return None + + user_id = self.cookies.get(self.cookie_name) + if user_id and self._is_valid_uuid(user_id): + return user_id + + return None + + except Exception as e: + logger.warning(f"Cookie取得エラー: {e}") + return None + + def _set_user_id_cookie(self, user_id: str): + """ユーザーIDをCookieに設定""" + try: + if self.cookies is None: + return + + # Cookieの有効期限を設定 + expiry_date = datetime.now() + timedelta(days=self.cookie_expiry_days) + + self.cookies[self.cookie_name] = user_id + self.cookies.save() + + logger.debug(f"ユーザーIDをCookieに保存: {user_id[:8]}...") + + except Exception as e: + logger.warning(f"Cookie設定エラー: {e}") + + def _is_valid_uuid(self, uuid_string: str) -> bool: + """UUIDの形式チェック""" + try: + uuid.UUID(uuid_string, version=4) + return True + except (ValueError, TypeError): + return False + + def _is_valid_user_id(self, user_id: str) -> bool: + """ユーザーIDの有効性チェック""" + try: + if not self._is_valid_uuid(user_id): + return False + + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + return os.path.exists(user_file) + + except Exception as e: + logger.warning(f"ユーザーID検証エラー: {e}") + return False + + def _create_new_user(self) -> str: + """新規ユーザーを作成""" + try: + user_id = str(uuid.uuid4()) + + # ユーザーデータを作成 + user_data = { + "user_id": user_id, + "created_at": datetime.now().isoformat(), + "last_access": datetime.now().isoformat(), + "version": "1.0", + "game_data": { + "affection": 30, + "messages": [{"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}], + "scene_params": {"theme": "default"}, + "ura_mode": False + }, + "settings": { + "notifications_enabled": True, + "auto_save": True + } + } + + # ファイルに保存 + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + with open(user_file, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + + # Cookieとセッション状態に保存 + self._set_user_id_cookie(user_id) + st.session_state.persistent_user_id = user_id + + logger.info(f"新規ユーザー作成完了: {user_id[:8]}...") + return user_id + + except Exception as e: + logger.error(f"新規ユーザー作成エラー: {e}") + # フォールバック + fallback_id = str(uuid.uuid4()) + st.session_state.persistent_user_id = fallback_id + return fallback_id + + def _update_user_access_time(self, user_id: str): + """ユーザーの最終アクセス時刻を更新""" + try: + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + + if os.path.exists(user_file): + with open(user_file, 'r', encoding='utf-8') as f: + user_data = json.load(f) + + user_data["last_access"] = datetime.now().isoformat() + + with open(user_file, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + + logger.debug(f"ユーザーアクセス時刻更新: {user_id[:8]}...") + + except Exception as e: + logger.warning(f"アクセス時刻更新エラー: {e}") + + def load_user_game_data(self, user_id: str) -> Optional[Dict[str, Any]]: + """ + ユーザーのゲームデータを読み込み + + Args: + user_id: ユーザーID + + Returns: + ゲームデータ(存在しない場合はNone) + """ + try: + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + + if not os.path.exists(user_file): + logger.info(f"ユーザーファイルが存在しません: {user_id[:8]}...") + return None + + with open(user_file, 'r', encoding='utf-8') as f: + user_data = json.load(f) + + game_data = user_data.get("game_data", {}) + logger.info(f"ゲームデータ読み込み完了: {user_id[:8]}... (データサイズ: {len(str(game_data))}文字)") + + return game_data + + except Exception as e: + logger.error(f"ゲームデータ読み込みエラー: {e}") + return None + + def save_user_game_data(self, user_id: str, game_data: Dict[str, Any]) -> bool: + """ + ユーザーのゲームデータを保存 + + Args: + user_id: ユーザーID + game_data: 保存するゲームデータ + + Returns: + 保存成功時True + """ + try: + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + + # 既存データを読み込み + if os.path.exists(user_file): + with open(user_file, 'r', encoding='utf-8') as f: + user_data = json.load(f) + else: + # 新規ユーザーデータを作成 + user_data = { + "user_id": user_id, + "created_at": datetime.now().isoformat(), + "version": "1.0" + } + + # ゲームデータを更新 + user_data["game_data"] = game_data + user_data["last_access"] = datetime.now().isoformat() + user_data["last_save"] = datetime.now().isoformat() + + # ファイルに保存 + with open(user_file, 'w', encoding='utf-8') as f: + json.dump(user_data, f, ensure_ascii=False, indent=2) + + logger.info(f"ゲームデータ保存完了: {user_id[:8]}...") + return True + + except Exception as e: + logger.error(f"ゲームデータ保存エラー: {e}") + return False + + def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]: + """ + ユーザー情報を取得 + + Args: + user_id: ユーザーID + + Returns: + ユーザー情報 + """ + try: + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + + if os.path.exists(user_file): + with open(user_file, 'r', encoding='utf-8') as f: + return json.load(f) + + return None + + except Exception as e: + logger.error(f"ユーザー情報取得エラー: {e}") + return None + + def delete_user_data(self, user_id: str) -> bool: + """ + ユーザーデータを削除 + + Args: + user_id: 削除するユーザーID + + Returns: + 削除成功時True + """ + try: + user_file = os.path.join(self.user_data_dir, f"{user_id}.json") + + if os.path.exists(user_file): + os.remove(user_file) + logger.info(f"ユーザーデータ削除: {user_id[:8]}...") + + # Cookieも削除 + if self.cookies: + if self.cookie_name in self.cookies: + del self.cookies[self.cookie_name] + self.cookies.save() + + # セッション状態からも削除 + if 'persistent_user_id' in st.session_state: + del st.session_state.persistent_user_id + + return True + + except Exception as e: + logger.error(f"ユーザーデータ削除エラー: {e}") + return False + + def list_all_users(self) -> List[Dict[str, Any]]: + """ + 全ユーザーの一覧を取得(管理用) + + Returns: + ユーザー情報のリスト + """ + try: + users = [] + + for filename in os.listdir(self.user_data_dir): + if filename.endswith('.json'): + user_file = os.path.join(self.user_data_dir, filename) + + try: + with open(user_file, 'r', encoding='utf-8') as f: + user_data = json.load(f) + + # 基本情報のみ抽出 + user_info = { + "user_id": user_data.get("user_id", "unknown")[:8] + "...", + "created_at": user_data.get("created_at", "unknown"), + "last_access": user_data.get("last_access", "unknown"), + "has_game_data": "game_data" in user_data, + "file_size": os.path.getsize(user_file) + } + + users.append(user_info) + + except Exception as e: + logger.warning(f"ユーザーファイル読み込みエラー {filename}: {e}") + + return users + + except Exception as e: + logger.error(f"ユーザー一覧取得エラー: {e}") + return [] + + def cleanup_old_users(self, days_threshold: int = 30) -> int: + """ + 古いユーザーデータをクリーンアップ + + Args: + days_threshold: 削除対象の日数閾値 + + Returns: + 削除されたユーザー数 + """ + try: + current_time = datetime.now() + deleted_count = 0 + + for filename in os.listdir(self.user_data_dir): + if filename.endswith('.json'): + user_file = os.path.join(self.user_data_dir, filename) + + try: + with open(user_file, 'r', encoding='utf-8') as f: + user_data = json.load(f) + + last_access = datetime.fromisoformat(user_data.get("last_access", "")) + if (current_time - last_access).days > days_threshold: + os.remove(user_file) + deleted_count += 1 + logger.info(f"古いユーザーデータ削除: {filename}") + + except Exception as e: + logger.warning(f"クリーンアップ処理エラー {filename}: {e}") + + logger.info(f"ユーザーデータクリーンアップ完了: {deleted_count}件削除") + return deleted_count + + except Exception as e: + logger.error(f"クリーンアップエラー: {e}") + return 0 + + def get_storage_stats(self) -> Dict[str, Any]: + """ + ストレージ使用状況を取得 + + Returns: + ストレージ統計情報 + """ + try: + stats = { + "user_count": 0, + "total_size": 0, + "storage_path": self.user_data_dir, + "cookie_enabled": self.cookies is not None + } + + if os.path.exists(self.user_data_dir): + for filename in os.listdir(self.user_data_dir): + if filename.endswith('.json'): + user_file = os.path.join(self.user_data_dir, filename) + stats["user_count"] += 1 + stats["total_size"] += os.path.getsize(user_file) + + return stats + + except Exception as e: + logger.error(f"ストレージ統計取得エラー: {e}") + return {"error": str(e)} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..c1e402e5e03063a8e49e6b109dd47c23d119c3a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +streamlit>=1.28.0 +python-dotenv>=1.0.0 +openai>=1.0.0 +groq>=0.4.0 +requests>=2.25.0 +unidic-lite +typing-extensions>=4.0.0 +transformers>=4.20.0 +torch>=1.12.0 +sentencepiece>=0.1.95 +fugashi>=1.1.0 +ipadic>=1.0.0 +aiohttp>=3.8.0 +aiofiles>=23.0.0 +asyncio-throttle>=1.0.0 +pydantic>=2.0.0 +fastapi>=0.104.0 +uvicorn>=0.24.0 \ No newline at end of file diff --git a/requirements_persistent.txt b/requirements_persistent.txt new file mode 100644 index 0000000000000000000000000000000000000000..9eb1c8590d6c2b3231f382f582ec89ce785c8af2 --- /dev/null +++ b/requirements_persistent.txt @@ -0,0 +1,2 @@ +# 永続ストレージ対応ユーザー管理システム用の追加依存関係 +streamlit-cookies-manager==0.2.0 \ No newline at end of file diff --git a/requirements_session.txt b/requirements_session.txt new file mode 100644 index 0000000000000000000000000000000000000000..cdc96ea7cd1856d23673528ed23b00aac21ffadc --- /dev/null +++ b/requirements_session.txt @@ -0,0 +1,4 @@ +# FastAPIセッション管理サーバー用の追加依存関係 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +requests==2.31.0 \ No newline at end of file diff --git a/ribinngu-hiru.jpg b/ribinngu-hiru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2b63d76b16912ba3ace5d200a7abdfed162fe3a9 --- /dev/null +++ b/ribinngu-hiru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0f3ac5b5101c730db7459882678f1ee730b597c8e3de42098472df9071833d2 +size 1933559 diff --git a/ribinngu-yoru-on.jpg b/ribinngu-yoru-on.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1c8b96ee001156d8bae28899f22566f0d56a6edb --- /dev/null +++ b/ribinngu-yoru-on.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9f18e27879c67372c18edbaf8c6fbccbf79637162b810f5559a675a8ccfe804 +size 2102063 diff --git a/session_api_client.py b/session_api_client.py new file mode 100644 index 0000000000000000000000000000000000000000..cb0a41ab0e052b6d5b18e7a3fb6a9af80f184dc9 --- /dev/null +++ b/session_api_client.py @@ -0,0 +1,494 @@ +""" +FastAPIセッション管理クライアント +HttpOnlyクッキーによるセキュアなセッション管理のクライアント側実装 +""" +import requests +import json +import os +from datetime import datetime +from typing import Optional, Dict, Any +import streamlit as st +import logging + +logger = logging.getLogger(__name__) + +class SessionAPIClient: + """FastAPIセッションサーバーとの通信クライアント""" + + def __init__(self, api_base_url: str = None): + """ + 初期化 + + Args: + api_base_url: FastAPIサーバーのベースURL(Noneの場合は自動決定) + """ + # Hugging Face Spacesでの実行を考慮してベースURLを自動決定 + if api_base_url is None: + is_spaces = os.getenv("SPACE_ID") is not None + if is_spaces: + # Hugging Face Spacesでは同一コンテナ内なのでlocalhostを使用 + api_base_url = "http://localhost:8000" + else: + api_base_url = "http://127.0.0.1:8000" + + self.api_base_url = api_base_url + self.session = requests.Session() + + # リクエストタイムアウト設定 + self.timeout = 10 + + # セッション情報をStreamlitの状態に保存 + if 'session_info' not in st.session_state: + st.session_state.session_info = {} + + def create_session(self) -> Optional[str]: + """ + 新しいセッションを作成 + + Returns: + セッションID(成功時)、None(失敗時) + """ + try: + url = f"{self.api_base_url}/session/create" + response = self.session.post(url, timeout=self.timeout) + + if response.status_code == 200: + data = response.json() + session_id = data.get('session_id') + + # セッション情報を保存 + st.session_state.session_info = { + 'session_id': session_id, + 'created_at': datetime.now().isoformat(), + 'status': 'active' + } + + logger.info(f"新規セッション作成成功: {session_id[:8]}...") + return session_id + else: + logger.error(f"セッション作成失敗: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + logger.error(f"セッション作成リクエストエラー: {e}") + return None + except Exception as e: + logger.error(f"セッション作成エラー: {e}") + return None + + def validate_session(self) -> bool: + """ + 現在のセッションの有効性を検証 + + Returns: + 有効な場合True + """ + try: + # session_infoが存在しない場合は無効とみなす + if 'session_info' not in st.session_state: + logger.debug("session_info未存在 - セッション無効") + return False + + url = f"{self.api_base_url}/session/validate" + response = self.session.post(url, timeout=self.timeout) + + if response.status_code == 200: + data = response.json() + is_valid = data.get('valid', False) + + if is_valid: + # セッション情報を更新 + session_id = data.get('session_id') + st.session_state.session_info.update({ + 'session_id': session_id, + 'last_validated': datetime.now().isoformat(), + 'status': 'active' + }) + logger.debug(f"セッション検証成功: {session_id[:8]}...") + else: + # 無効なセッション + if 'session_info' in st.session_state: + st.session_state.session_info['status'] = 'invalid' + logger.warning("セッション検証失敗: 無効なセッション") + + return is_valid + else: + logger.error(f"セッション検証失敗: {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"セッション検証リクエストエラー: {e}") + return False + except Exception as e: + logger.error(f"セッション検証エラー: {e}") + return False + + def get_session_info(self) -> Optional[Dict[str, Any]]: + """ + セッション情報を取得 + + Returns: + セッション情報(成功時)、None(失敗時) + """ + try: + url = f"{self.api_base_url}/session/info" + response = self.session.get(url, timeout=self.timeout) + + if response.status_code == 200: + data = response.json() + + # セッション情報を更新 + st.session_state.session_info.update({ + 'session_id': data.get('session_id'), + 'created_at': data.get('created_at'), + 'last_access': data.get('last_access'), + 'status': 'active' + }) + + return data + else: + logger.error(f"セッション情報取得失敗: {response.status_code}") + return None + + except requests.exceptions.RequestException as e: + logger.error(f"セッション情報取得リクエストエラー: {e}") + return None + except Exception as e: + logger.error(f"セッション情報取得エラー: {e}") + return None + + def delete_session(self) -> bool: + """ + 現在のセッションを削除 + + Returns: + 削除成功時True + """ + try: + url = f"{self.api_base_url}/session/delete" + response = self.session.delete(url, timeout=self.timeout) + + if response.status_code == 200: + # セッション情報をクリア + st.session_state.session_info = { + 'status': 'deleted', + 'deleted_at': datetime.now().isoformat() + } + + logger.info("セッション削除成功") + return True + else: + logger.error(f"セッション削除失敗: {response.status_code}") + return False + + except requests.exceptions.RequestException as e: + logger.error(f"セッション削除リクエストエラー: {e}") + return False + except Exception as e: + logger.error(f"セッション削除エラー: {e}") + return False + + def get_or_create_session_id(self) -> str: + """ + セッションIDを取得または新規作成 + 複数セッション生成を防ぐため、既存セッション情報を最優先でチェック + + Returns: + セッションID + """ + try: + # 既存のセッション情報をチェック + existing_session_info = st.session_state.get('session_info', {}) + existing_session_id = existing_session_info.get('session_id') + + # 既存セッションがある場合は、基本的にそれを使用(検証は最小限に) + if existing_session_id: + logger.debug(f"既存セッション情報発見: {existing_session_id[:8]}...") + + # セッション状態をチェック + session_status = existing_session_info.get('status', 'unknown') + + # 明示的に無効とマークされていない限り、既存セッションを使用 + if session_status != 'invalid': + logger.debug(f"既存セッション使用: {existing_session_id[:8]}... (status: {session_status})") + return existing_session_id + else: + logger.info(f"無効セッション検出: {existing_session_id[:8]}... - 新規作成") + + # 新しいセッションを作成(一度だけ) + logger.info("新規セッション作成開始...") + + # セッション作成中フラグを設定(重複作成防止) + if st.session_state.get('session_creating', False): + logger.warning("セッション作成中 - 待機") + # 既存のセッション情報があればそれを返す + if existing_session_id: + return existing_session_id + # なければフォールバック + import uuid + return str(uuid.uuid4()) + + st.session_state.session_creating = True + + try: + if self.is_server_available(): + session_id = self.create_session() + if session_id: + logger.info(f"新規セッション作成成功: {session_id[:8]}...") + return session_id + + # フォールバック: ローカルセッションID生成 + import uuid + fallback_id = str(uuid.uuid4()) + logger.warning(f"フォールバックセッションID生成: {fallback_id[:8]}...") + + # フォールバックセッション情報を保存 + st.session_state.session_info = { + 'session_id': fallback_id, + 'created_at': datetime.now().isoformat(), + 'fallback_mode': True, + 'server_available': False, + 'status': 'fallback' + } + + return fallback_id + + finally: + # セッション作成中フラグをクリア + st.session_state.session_creating = False + + except Exception as e: + logger.error(f"セッションID取得エラー: {e}") + # セッション作成中フラグをクリア + st.session_state.session_creating = False + + # 最終フォールバック + import uuid + fallback_id = str(uuid.uuid4()) + logger.error(f"最終フォールバックセッションID: {fallback_id[:8]}...") + return fallback_id + + def is_server_available(self) -> bool: + """ + FastAPIサーバーが利用可能かチェック + + Returns: + 利用可能な場合True + """ + try: + url = f"{self.api_base_url}/health" + response = self.session.get(url, timeout=5) + return response.status_code == 200 + except: + return False + + def get_session_status(self) -> Dict[str, Any]: + """ + 現在のセッション状態を取得 + + Returns: + セッション状態の辞書 + """ + session_info = st.session_state.get('session_info', {}) + + return { + 'session_id': session_info.get('session_id', 'unknown')[:8] + "..." if session_info.get('session_id') else 'none', + 'status': session_info.get('status', 'unknown'), + 'created_at': session_info.get('created_at'), + 'last_access': session_info.get('last_access'), + 'last_validated': session_info.get('last_validated'), + 'server_available': self.is_server_available() + } + + def reset_session(self) -> str: + """ + セッションをリセット(削除して新規作成) + + Returns: + 新しいセッションID + """ + try: + # 既存セッションを削除 + self.delete_session() + + # 新しいセッションを作成 + new_session_id = self.create_session() + if new_session_id: + logger.info(f"セッションリセット完了: {new_session_id[:8]}...") + return new_session_id + + # フォールバック + import uuid + fallback_id = str(uuid.uuid4()) + logger.warning(f"セッションリセット失敗、フォールバック使用: {fallback_id[:8]}...") + return fallback_id + + except Exception as e: + logger.error(f"セッションリセットエラー: {e}") + import uuid + return str(uuid.uuid4()) + + def get_cookie_status(self) -> Dict[str, Any]: + """ + 現在のCookie状態を取得 + + Returns: + Cookie状態の辞書 + """ + try: + cookie_info = { + 'count': len(self.session.cookies), + 'cookies': [], + 'has_session_cookie': False, + 'timestamp': datetime.now().isoformat() + } + + for cookie in self.session.cookies: + cookie_data = { + 'name': cookie.name, + 'domain': cookie.domain, + 'path': cookie.path, + 'secure': cookie.secure, + 'expires': cookie.expires + } + cookie_info['cookies'].append(cookie_data) + + # セッション関連のCookieをチェック + if 'session' in cookie.name.lower(): + cookie_info['has_session_cookie'] = True + + return cookie_info + + except Exception as e: + logger.error(f"Cookie状態取得エラー: {e}") + return { + 'count': 0, + 'cookies': [], + 'has_session_cookie': False, + 'error': str(e), + 'timestamp': datetime.now().isoformat() + } + + def full_reset_session(self) -> Dict[str, Any]: + """ + フルリセット(Cookie削除 + 新規セッション作成) + サーバー接続エラーでも動作するフォールバック機能付き + + Returns: + リセット結果の辞書 + """ + try: + result = { + 'success': False, + 'old_session_id': None, + 'new_session_id': None, + 'message': '', + 'timestamp': datetime.now().isoformat(), + 'cookie_reset': False, + 'session_created': False, + 'server_available': False, + 'fallback_mode': False + } + + # 現在のセッションIDを記録 + current_session_info = st.session_state.get('session_info', {}) + old_session_id = current_session_info.get('session_id', st.session_state.get('user_id', 'unknown')) + result['old_session_id'] = old_session_id[:8] + "..." if len(old_session_id) > 8 else old_session_id + + logger.info(f"フルリセット開始 - 旧セッション: {result['old_session_id']}") + + # 1. サーバー接続テスト + server_available = self._test_server_connection() + result['server_available'] = server_available + + if server_available: + # サーバーが利用可能な場合の通常処理 + logger.info("サーバー利用可能 - 通常のフルリセット実行") + delete_success = self.delete_session() + logger.info(f"セッション削除結果: {delete_success}") + else: + # サーバーが利用できない場合のフォールバック処理 + logger.warning("サーバー接続不可 - フォールバックモードでリセット実行") + result['fallback_mode'] = True + + # 2. セッション情報を完全クリア + if 'session_info' in st.session_state: + del st.session_state.session_info + + # 3. 新しいrequestsセッションを作成(Cookie完全クリア) + old_session = self.session + cookie_count_before = len(old_session.cookies) + + self.session.close() + self.session = requests.Session() + + # Cookieが完全にクリアされたことを確認 + cookie_count_after = len(self.session.cookies) + result['cookie_reset'] = cookie_count_after == 0 + + logger.info(f"Cookie状態 - 削除前: {cookie_count_before}個, 削除後: {cookie_count_after}個") + + # 4. 新しいセッションを作成 + if server_available: + # サーバー経由でセッション作成 + new_session_id = self.create_session() + else: + # フォールバック: ローカルでセッションID生成 + import uuid + new_session_id = str(uuid.uuid4()) + logger.info(f"フォールバックモード: ローカルセッションID生成 - {new_session_id[:8]}...") + + if new_session_id: + result['success'] = True + result['session_created'] = True + result['new_session_id'] = new_session_id[:8] + "..." + + if result['fallback_mode']: + result['message'] = 'フルリセット成功(フォールバックモード) - Cookie削除&ローカルセッション作成完了' + else: + result['message'] = 'フルリセット成功 - Cookie削除&新規セッション作成完了' + + logger.info(f"フルリセット成功: {result['old_session_id']} → {result['new_session_id']}") + + # 新しいセッション情報をStreamlitセッションに保存 + st.session_state.session_info = { + 'session_id': new_session_id, + 'created_at': datetime.now().isoformat(), + 'reset_count': st.session_state.get('reset_count', 0) + 1, + 'fallback_mode': result['fallback_mode'], + 'server_available': result['server_available'] + } + + else: + result['message'] = 'Cookie削除成功、セッション作成失敗' + logger.error("フルリセット: 新規セッション作成失敗") + + return result + + except Exception as e: + logger.error(f"フルリセットエラー: {e}") + return { + 'success': False, + 'old_session_id': 'error', + 'new_session_id': None, + 'message': f'エラー: {str(e)}', + 'timestamp': datetime.now().isoformat(), + 'cookie_reset': False, + 'session_created': False, + 'server_available': False, + 'fallback_mode': True + } + + def _test_server_connection(self) -> bool: + """ + サーバー接続をテストする + + Returns: + 接続可能かどうか + """ + try: + response = requests.get(f"{self.base_url}/health", timeout=2) + return response.status_code == 200 + except Exception as e: + logger.debug(f"サーバー接続テスト失敗: {e}") + return False \ No newline at end of file diff --git a/session_api_server.py b/session_api_server.py new file mode 100644 index 0000000000000000000000000000000000000000..e28212a6c18ee40f93c7f0ca3efd5db6879f1e13 --- /dev/null +++ b/session_api_server.py @@ -0,0 +1,298 @@ +""" +FastAPIベースのセッション管理サーバー +HttpOnlyクッキーによるセキュアなセッション管理を提供 +""" +from fastapi import FastAPI, Request, Response, HTTPException +from fastapi.middleware.cors import CORSMiddleware +import uuid +import json +import os +import time +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +import logging + +# ログ設定 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Mari Session API", version="1.0.0") + +# CORS設定(Streamlitからのアクセスを許可) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:8501", + "https://localhost:8501", + "http://127.0.0.1:8501", + "https://127.0.0.1:8501", + "*" # Hugging Face Spacesでの実行を考慮 + ], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], +) + +class SessionManager: + """セッション管理クラス""" + + def __init__(self, storage_path: str = "session_data"): + self.storage_path = storage_path + self.cookie_name = "mari_session_id" + self.session_duration_days = 7 + self.cleanup_interval_hours = 24 + + # ストレージディレクトリを作成 + os.makedirs(self.storage_path, exist_ok=True) + + # 最後のクリーンアップ時刻を記録するファイル + self.cleanup_file = os.path.join(self.storage_path, "last_cleanup.json") + + def create_session(self) -> str: + """新しいセッションを作成""" + session_id = str(uuid.uuid4()) + + session_data = { + 'session_id': session_id, + 'created_at': datetime.now().isoformat(), + 'last_access': datetime.now().isoformat(), + 'user_data': {} + } + + # セッションファイルに保存 + session_file = os.path.join(self.storage_path, f"{session_id}.json") + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(session_data, f, ensure_ascii=False, indent=2) + + logger.info(f"新規セッション作成: {session_id[:8]}...") + return session_id + + def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: + """セッションデータを取得""" + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if not os.path.exists(session_file): + return None + + with open(session_file, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + # 期限チェック + last_access = datetime.fromisoformat(session_data.get('last_access', '')) + expiry_time = last_access + timedelta(days=self.session_duration_days) + + if datetime.now() > expiry_time: + # 期限切れセッションを削除 + os.remove(session_file) + logger.info(f"期限切れセッション削除: {session_id[:8]}...") + return None + + return session_data + + except Exception as e: + logger.error(f"セッション取得エラー: {e}") + return None + + def update_session_access(self, session_id: str) -> bool: + """セッションの最終アクセス時刻を更新""" + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if not os.path.exists(session_file): + return False + + with open(session_file, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + session_data['last_access'] = datetime.now().isoformat() + + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(session_data, f, ensure_ascii=False, indent=2) + + return True + + except Exception as e: + logger.error(f"セッションアクセス時刻更新エラー: {e}") + return False + + def delete_session(self, session_id: str) -> bool: + """セッションを削除""" + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if os.path.exists(session_file): + os.remove(session_file) + logger.info(f"セッション削除: {session_id[:8]}...") + return True + + return False + + except Exception as e: + logger.error(f"セッション削除エラー: {e}") + return False + + def cleanup_expired_sessions(self): + """期限切れセッションのクリーンアップ""" + try: + current_time = datetime.now() + cleaned_count = 0 + + for filename in os.listdir(self.storage_path): + if filename.endswith('.json') and filename != 'last_cleanup.json': + session_file = os.path.join(self.storage_path, filename) + + try: + with open(session_file, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + last_access = datetime.fromisoformat(session_data.get('last_access', '')) + expiry_time = last_access + timedelta(days=self.session_duration_days) + + if current_time > expiry_time: + os.remove(session_file) + cleaned_count += 1 + + except Exception as e: + logger.warning(f"セッションファイル処理エラー {filename}: {e}") + + if cleaned_count > 0: + logger.info(f"期限切れセッション {cleaned_count}件を削除") + + except Exception as e: + logger.error(f"セッションクリーンアップエラー: {e}") + +# セッションマネージャーのインスタンス +session_manager = SessionManager() + +@app.post("/session/create") +async def create_session(response: Response): + """新しいセッションを作成してCookieを設定""" + try: + # 期限切れセッションのクリーンアップ + session_manager.cleanup_expired_sessions() + + # 新しいセッションを作成 + session_id = session_manager.create_session() + + # HttpOnlyクッキーを設定(Hugging Face Spaces対応) + is_production = os.getenv("SPACE_ID") is not None # Hugging Face Spacesの環境変数 + + response.set_cookie( + key=session_manager.cookie_name, + value=session_id, + max_age=session_manager.session_duration_days * 24 * 60 * 60, # 7日間 + httponly=True, + secure=is_production, # 本番環境(HTTPS)でのみSecure属性を有効 + samesite="lax" if is_production else "strict" # 本番環境では緩和 + ) + + return { + "status": "success", + "session_id": session_id, + "message": "セッションが作成されました" + } + + except Exception as e: + logger.error(f"セッション作成エラー: {e}") + raise HTTPException(status_code=500, detail="セッション作成に失敗しました") + +@app.get("/session/info") +async def get_session_info(request: Request): + """現在のセッション情報を取得""" + try: + # CookieからセッションIDを取得 + session_id = request.cookies.get(session_manager.cookie_name) + + if not session_id: + raise HTTPException(status_code=401, detail="セッションが見つかりません") + + # セッションデータを取得 + session_data = session_manager.get_session(session_id) + + if not session_data: + raise HTTPException(status_code=401, detail="無効なセッションです") + + # アクセス時刻を更新 + session_manager.update_session_access(session_id) + + return { + "status": "success", + "session_id": session_id, + "created_at": session_data.get('created_at'), + "last_access": session_data.get('last_access') + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"セッション情報取得エラー: {e}") + raise HTTPException(status_code=500, detail="セッション情報の取得に失敗しました") + +@app.post("/session/validate") +async def validate_session(request: Request): + """セッションの有効性を検証""" + try: + # CookieからセッションIDを取得 + session_id = request.cookies.get(session_manager.cookie_name) + + if not session_id: + return {"valid": False, "message": "セッションが見つかりません"} + + # セッションデータを取得 + session_data = session_manager.get_session(session_id) + + if not session_data: + return {"valid": False, "message": "無効なセッションです"} + + # アクセス時刻を更新 + session_manager.update_session_access(session_id) + + return { + "valid": True, + "session_id": session_id, + "message": "有効なセッションです" + } + + except Exception as e: + logger.error(f"セッション検証エラー: {e}") + return {"valid": False, "message": "セッション検証に失敗しました"} + +@app.delete("/session/delete") +async def delete_session(request: Request, response: Response): + """セッションを削除""" + try: + # CookieからセッションIDを取得 + session_id = request.cookies.get(session_manager.cookie_name) + + if session_id: + # セッションファイルを削除 + session_manager.delete_session(session_id) + + # Cookieを削除(Hugging Face Spaces対応) + is_production = os.getenv("SPACE_ID") is not None + + response.delete_cookie( + key=session_manager.cookie_name, + httponly=True, + secure=is_production, + samesite="lax" if is_production else "strict" + ) + + return { + "status": "success", + "message": "セッションが削除されました" + } + + except Exception as e: + logger.error(f"セッション削除エラー: {e}") + raise HTTPException(status_code=500, detail="セッション削除に失敗しました") + +@app.get("/health") +async def health_check(): + """ヘルスチェック""" + return {"status": "healthy", "timestamp": datetime.now().isoformat()} + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info") \ No newline at end of file diff --git a/session_cookie_manager.py b/session_cookie_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..933a608031ed3aa8548dd7c20f21e1526c64827b --- /dev/null +++ b/session_cookie_manager.py @@ -0,0 +1,383 @@ +""" +セッションCookie管理システム +UUIDベースのユーザー識別とCookie管理を提供する +""" +import uuid +import json +import os +import time +from datetime import datetime, timedelta +from typing import Optional, Dict, Any +import streamlit as st +import logging + +logger = logging.getLogger(__name__) + +class SessionCookieManager: + """セッションCookie管理クラス""" + + def __init__(self, storage_path: str = "session_data"): + """ + 初期化 + + Args: + storage_path: セッションデータの保存パス + """ + self.storage_path = storage_path + self.cookie_name = "mari_session_id" + self.session_duration_days = 7 + self.cleanup_interval_hours = 24 + + # ストレージディレクトリを作成 + os.makedirs(self.storage_path, exist_ok=True) + + # 最後のクリーンアップ時刻を記録するファイル + self.cleanup_file = os.path.join(self.storage_path, "last_cleanup.json") + + def get_or_create_session_id(self) -> str: + """ + セッションIDを取得または新規作成 + + Returns: + セッションID(UUID4形式) + """ + try: + # 既存のセッションIDを確認 + session_id = self._get_session_id_from_state() + + if session_id and self._is_valid_session(session_id): + # 有効なセッションIDが存在する場合 + self._update_session_access_time(session_id) + logger.info(f"既存セッションID使用: {session_id[:8]}...") + return session_id + + # 新しいセッションIDを生成 + session_id = str(uuid.uuid4()) + self._create_new_session(session_id) + logger.info(f"新規セッションID生成: {session_id[:8]}...") + + return session_id + + except Exception as e: + logger.error(f"セッションID取得エラー: {e}") + # フォールバック:一時的なセッションID + return str(uuid.uuid4()) + + def _get_session_id_from_state(self) -> Optional[str]: + """ + Streamlitの状態からセッションIDを取得 + + Returns: + セッションID(存在しない場合はNone) + """ + # Streamlitのセッション状態から取得 + session_id = st.session_state.get('mari_session_id') + + if session_id: + return session_id + + # URLパラメータから取得を試行(フォールバック) + query_params = st.query_params + if 'session_id' in query_params: + session_id = query_params['session_id'] + if self._is_valid_uuid(session_id): + return session_id + + return None + + def _is_valid_uuid(self, uuid_string: str) -> bool: + """ + UUIDの形式が正しいかチェック + + Args: + uuid_string: チェックするUUID文字列 + + Returns: + 有効な場合True + """ + try: + uuid.UUID(uuid_string, version=4) + return True + except (ValueError, TypeError): + return False + + def _is_valid_session(self, session_id: str) -> bool: + """ + セッションが有効かチェック + + Args: + session_id: チェックするセッションID + + Returns: + 有効な場合True + """ + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if not os.path.exists(session_file): + return False + + with open(session_file, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + # 最終アクセス時刻をチェック + last_access = datetime.fromisoformat(session_data.get('last_access', '')) + expiry_time = last_access + timedelta(days=self.session_duration_days) + + return datetime.now() < expiry_time + + except Exception as e: + logger.warning(f"セッション検証エラー: {e}") + return False + + def _create_new_session(self, session_id: str) -> None: + """ + 新しいセッションを作成 + + Args: + session_id: 新しいセッションID + """ + try: + # セッションデータを作成 + session_data = { + 'session_id': session_id, + 'created_at': datetime.now().isoformat(), + 'last_access': datetime.now().isoformat(), + 'user_agent': self._get_user_agent(), + 'ip_hash': self._get_ip_hash() + } + + # セッションファイルに保存 + session_file = os.path.join(self.storage_path, f"{session_id}.json") + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(session_data, f, ensure_ascii=False, indent=2) + + # Streamlitの状態に保存 + st.session_state.mari_session_id = session_id + + # Cookie設定のJavaScriptを生成(セキュア設定) + self._set_secure_cookie(session_id) + + except Exception as e: + logger.error(f"新規セッション作成エラー: {e}") + + def _update_session_access_time(self, session_id: str) -> None: + """ + セッションの最終アクセス時刻を更新 + + Args: + session_id: 更新するセッションID + """ + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if os.path.exists(session_file): + with open(session_file, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + session_data['last_access'] = datetime.now().isoformat() + + with open(session_file, 'w', encoding='utf-8') as f: + json.dump(session_data, f, ensure_ascii=False, indent=2) + + except Exception as e: + logger.warning(f"セッションアクセス時刻更新エラー: {e}") + + def _set_secure_cookie(self, session_id: str) -> None: + """ + セキュアなCookieを設定 + + Args: + session_id: 設定するセッションID + """ + try: + # セキュアCookie設定のJavaScript + cookie_js = f""" + + """ + + st.markdown(cookie_js, unsafe_allow_html=True) + + except Exception as e: + logger.error(f"セキュアCookie設定エラー: {e}") + + def _get_user_agent(self) -> str: + """ + ユーザーエージェントを取得(可能な場合) + + Returns: + ユーザーエージェント文字列 + """ + try: + # Streamlitでは直接取得できないため、JavaScriptで取得 + return "Streamlit-Client" + except Exception: + return "Unknown" + + def _get_ip_hash(self) -> str: + """ + IPアドレスのハッシュを取得(プライバシー保護) + + Returns: + IPアドレスのハッシュ + """ + try: + import hashlib + # 実際のIPアドレスは取得せず、セッション識別用のハッシュのみ + session_hash = hashlib.sha256(str(time.time()).encode()).hexdigest()[:16] + return session_hash + except Exception: + return "unknown" + + def cleanup_expired_sessions(self) -> None: + """ + 期限切れセッションのクリーンアップ + """ + try: + # 前回のクリーンアップ時刻をチェック + if not self._should_cleanup(): + return + + current_time = datetime.now() + cleaned_count = 0 + + # セッションファイルをスキャン + for filename in os.listdir(self.storage_path): + if filename.endswith('.json') and filename != 'last_cleanup.json': + session_file = os.path.join(self.storage_path, filename) + + try: + with open(session_file, 'r', encoding='utf-8') as f: + session_data = json.load(f) + + last_access = datetime.fromisoformat(session_data.get('last_access', '')) + expiry_time = last_access + timedelta(days=self.session_duration_days) + + if current_time > expiry_time: + os.remove(session_file) + cleaned_count += 1 + logger.info(f"期限切れセッション削除: {filename}") + + except Exception as e: + logger.warning(f"セッションファイル処理エラー {filename}: {e}") + + # クリーンアップ時刻を記録 + self._update_cleanup_time() + + if cleaned_count > 0: + logger.info(f"セッションクリーンアップ完了: {cleaned_count}件削除") + + except Exception as e: + logger.error(f"セッションクリーンアップエラー: {e}") + + def _should_cleanup(self) -> bool: + """ + クリーンアップが必要かチェック + + Returns: + クリーンアップが必要な場合True + """ + try: + if not os.path.exists(self.cleanup_file): + return True + + with open(self.cleanup_file, 'r', encoding='utf-8') as f: + cleanup_data = json.load(f) + + last_cleanup = datetime.fromisoformat(cleanup_data.get('last_cleanup', '')) + next_cleanup = last_cleanup + timedelta(hours=self.cleanup_interval_hours) + + return datetime.now() > next_cleanup + + except Exception: + return True + + def _update_cleanup_time(self) -> None: + """ + クリーンアップ時刻を更新 + """ + try: + cleanup_data = { + 'last_cleanup': datetime.now().isoformat() + } + + with open(self.cleanup_file, 'w', encoding='utf-8') as f: + json.dump(cleanup_data, f, ensure_ascii=False, indent=2) + + except Exception as e: + logger.warning(f"クリーンアップ時刻更新エラー: {e}") + + def get_session_info(self, session_id: str) -> Dict[str, Any]: + """ + セッション情報を取得 + + Args: + session_id: セッションID + + Returns: + セッション情報の辞書 + """ + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if os.path.exists(session_file): + with open(session_file, 'r', encoding='utf-8') as f: + return json.load(f) + + return {} + + except Exception as e: + logger.error(f"セッション情報取得エラー: {e}") + return {} + + def delete_session(self, session_id: str) -> bool: + """ + セッションを削除 + + Args: + session_id: 削除するセッションID + + Returns: + 削除成功時True + """ + try: + session_file = os.path.join(self.storage_path, f"{session_id}.json") + + if os.path.exists(session_file): + os.remove(session_file) + logger.info(f"セッション削除: {session_id[:8]}...") + return True + + return False + + except Exception as e: + logger.error(f"セッション削除エラー: {e}") + return False \ No newline at end of file diff --git a/session_manager.py b/session_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..d369e4a7321b043f083ba200009d604c8715b552 --- /dev/null +++ b/session_manager.py @@ -0,0 +1,510 @@ +""" +セッション管理クラス - セッション分離強化のための基盤実装 + +このモジュールは、Streamlitアプリケーションにおけるセッション分離を強化し、 +複数ユーザー間でのデータ混在を防ぐためのSessionManagerクラスを提供します。 +""" + +import streamlit as st +import logging +from datetime import datetime +from typing import Dict, Any, Optional, List +import uuid + +logger = logging.getLogger(__name__) + + +class SessionManager: + """ + セッション管理クラス + + 各ユーザーセッションの独立性を保証し、セッション整合性の検証、 + セッション情報の取得機能を提供します。 + """ + + def __init__(self): + """ + SessionManagerを初期化する + + セッションID、ユーザーID、作成時刻を記録し、 + セッション固有の識別子を設定します。 + """ + # セッション固有の識別子を生成 + self.session_id = id(st.session_state) + self.user_id = None # 後でユーザーIDが設定される + self.created_at = datetime.now() + self.last_validated = datetime.now() + self.validation_count = 0 + self.recovery_count = 0 + + # 検証履歴の記録用リスト + self.validation_history: List[Dict[str, Any]] = [] + self.recovery_history: List[Dict[str, Any]] = [] + + logger.info(f"SessionManager initialized - Session ID: {self.session_id}") + + def set_user_id(self, user_id: str): + """ + ユーザーIDを設定する + + Args: + user_id (str): 設定するユーザーID + """ + self.user_id = user_id + logger.debug(f"User ID set for session {self.session_id}: {user_id}") + + def validate_session_integrity(self) -> bool: + """ + セッション整合性を検証する + + 現在のセッションIDと初期化時のセッションIDを比較し、 + セッションの整合性を確認します。 + + Returns: + bool: セッションが整合している場合True、そうでなければFalse + """ + current_session_id = id(st.session_state) + stored_session_id = st.session_state.get('_session_id') + is_consistent = self.session_id == current_session_id + + # 検証回数をカウント + self.validation_count += 1 + validation_time = datetime.now() + self.last_validated = validation_time + + # 検証履歴を記録 + validation_record = { + "timestamp": validation_time.isoformat(), + "validation_count": self.validation_count, + "original_session_id": self.session_id, + "current_session_id": current_session_id, + "stored_session_id": stored_session_id, + "is_consistent": is_consistent, + "session_keys_count": len(st.session_state.keys()), + "user_id": self.user_id + } + + # 履歴リストのサイズ制限(最新100件まで保持) + if len(self.validation_history) >= 100: + self.validation_history.pop(0) + + self.validation_history.append(validation_record) + + if not is_consistent: + logger.warning( + f"Session integrity violation detected! " + f"Original: {self.session_id}, Current: {current_session_id}, " + f"Stored: {stored_session_id}, Validation #{self.validation_count}" + ) + else: + logger.debug(f"Session integrity validated successfully (count: {self.validation_count})") + + return is_consistent + + def recover_session(self): + """ + セッション不整合時の復旧処理 + + セッションIDの不整合が検出された場合に、 + セッション状態を修正して整合性を回復します。 + """ + old_session_id = self.session_id + new_session_id = id(st.session_state) + recovery_time = datetime.now() + + # セッションIDを現在の値に更新 + self.session_id = new_session_id + self.recovery_count += 1 + self.last_validated = recovery_time + + # 復旧履歴を記録 + recovery_record = { + "timestamp": recovery_time.isoformat(), + "recovery_count": self.recovery_count, + "old_session_id": old_session_id, + "new_session_id": new_session_id, + "user_id": self.user_id, + "recovery_type": "session_id_mismatch", + "session_keys_count": len(st.session_state.keys()), + "validation_count_at_recovery": self.validation_count + } + + # 履歴リストのサイズ制限(最新50件まで保持) + if len(self.recovery_history) >= 50: + self.recovery_history.pop(0) + + self.recovery_history.append(recovery_record) + + logger.info( + f"Session recovered - Old ID: {old_session_id}, " + f"New ID: {new_session_id}, Recovery count: {self.recovery_count}" + ) + + # セッション状態にも新しいIDを記録 + st.session_state._session_id = new_session_id + + def get_session_info(self) -> Dict[str, Any]: + """ + セッション情報を取得する + + 現在のセッション状態、検証履歴、復旧履歴などの + 詳細な情報を辞書形式で返します。 + + Returns: + Dict[str, Any]: セッション情報を含む辞書 + """ + current_session_id = id(st.session_state) + is_consistent = self.session_id == current_session_id + + session_info = { + "session_id": self.session_id, + "current_session_id": current_session_id, + "user_id": self.user_id, + "created_at": self.created_at.isoformat(), + "last_validated": self.last_validated.isoformat(), + "validation_count": self.validation_count, + "recovery_count": self.recovery_count, + "is_consistent": is_consistent, + "session_age_seconds": (datetime.now() - self.created_at).total_seconds(), + "stored_session_id": st.session_state.get('_session_id'), + "session_keys": list(st.session_state.keys()), + "validation_history_count": len(self.validation_history), + "recovery_history_count": len(self.recovery_history), + "last_validation_result": self.validation_history[-1] if self.validation_history else None, + "last_recovery_result": self.recovery_history[-1] if self.recovery_history else None + } + + return session_info + + def get_validation_history(self, limit: int = 10) -> List[Dict[str, Any]]: + """ + 検証履歴を取得する + + Args: + limit (int): 取得する履歴の最大件数(デフォルト: 10) + + Returns: + List[Dict[str, Any]]: 検証履歴のリスト(新しい順) + """ + return self.validation_history[-limit:] if self.validation_history else [] + + def get_recovery_history(self, limit: int = 10) -> List[Dict[str, Any]]: + """ + 復旧履歴を取得する + + Args: + limit (int): 取得する履歴の最大件数(デフォルト: 10) + + Returns: + List[Dict[str, Any]]: 復旧履歴のリスト(新しい順) + """ + return self.recovery_history[-limit:] if self.recovery_history else [] + + def get_isolation_status(self) -> Dict[str, Any]: + """ + セッション分離状態を取得する + + 各コンポーネントの分離状態を確認し、 + セッション独立性の詳細情報を返します。 + + Returns: + Dict[str, Any]: 分離状態情報を含む辞書 + """ + isolation_status = { + "session_isolation": { + "session_manager_present": hasattr(st.session_state, '_session_manager'), + "session_id_consistent": self.validate_session_integrity(), + "user_id_set": self.user_id is not None + }, + "component_isolation": { + "chat_isolated": 'chat' in st.session_state, + "memory_isolated": 'memory_manager' in st.session_state, + "notifications_isolated": all(key in st.session_state for key in [ + 'memory_notifications', 'affection_notifications' + ]), + "rate_limit_isolated": 'chat' in st.session_state and + 'limiter_state' in st.session_state.get('chat', {}) + }, + "data_integrity": { + "chat_messages_count": len(st.session_state.get('chat', {}).get('messages', [])), + "memory_cache_size": len(getattr(st.session_state.get('memory_manager'), 'important_words_cache', [])), + "special_memories_count": len(getattr(st.session_state.get('memory_manager'), 'special_memories', [])), + "pending_notifications": { + "memory": len(st.session_state.get('memory_notifications', [])), + "affection": len(st.session_state.get('affection_notifications', [])) + } + } + } + + return isolation_status + + def reset_session_data(self): + """ + セッションデータを完全にリセットする + フルリセット時に使用 + """ + try: + # 検証履歴をクリア + self.validation_history.clear() + self.recovery_history.clear() + + # カウンターをリセット + self.validation_count = 0 + self.recovery_count = 0 + + # タイムスタンプを更新 + self.last_validated = datetime.now() + + logger.info("SessionManagerのデータをリセットしました") + + except Exception as e: + logger.error(f"SessionManagerリセットエラー: {e}") + + def get_isolation_summary(self) -> Dict[str, str]: + """ + セッション分離状態のサマリーを取得する + + デバッグ表示用の簡潔な分離状態情報を返します。 + + Returns: + Dict[str, str]: 分離状態のサマリー情報 + """ + isolation_status = self.get_isolation_status() + + # 各カテゴリの状態を評価 + session_ok = all(isolation_status["session_isolation"].values()) + components_ok = all(isolation_status["component_isolation"].values()) + + # データ整合性の評価 + data_integrity = isolation_status["data_integrity"] + data_ok = ( + data_integrity["chat_messages_count"] >= 0 and + data_integrity["memory_cache_size"] >= 0 and + data_integrity["special_memories_count"] >= 0 + ) + + # 全体的な健全性評価 + overall_health = "正常" if (session_ok and components_ok and data_ok) else "要注意" + + summary = { + "overall_health": overall_health, + "session_isolation": "✅ 正常" if session_ok else "❌ 問題あり", + "component_isolation": "✅ 正常" if components_ok else "❌ 問題あり", + "data_integrity": "✅ 正常" if data_ok else "❌ 問題あり", + "validation_status": f"検証{self.validation_count}回/復旧{self.recovery_count}回", + "session_age": f"{round((datetime.now() - self.created_at).total_seconds() / 60, 1)}分", + "last_check": f"{round((datetime.now() - self.last_validated).total_seconds(), 1)}秒前" + } + + return summary + + def __str__(self) -> str: + """ + SessionManagerの文字列表現 + + Returns: + str: セッション情報の要約 + """ + return ( + f"SessionManager(session_id={self.session_id}, " + f"user_id={self.user_id}, " + f"validations={self.validation_count}, " + f"recoveries={self.recovery_count})" + ) + + def __repr__(self) -> str: + """ + SessionManagerの詳細な文字列表現 + + Returns: + str: セッション情報の詳細 + """ + return self.__str__() + + +def get_session_manager() -> SessionManager: + """ + 現在のセッションのSessionManagerインスタンスを取得する + + セッション状態にSessionManagerが存在しない場合は新規作成します。 + + Returns: + SessionManager: 現在のセッションのSessionManagerインスタンス + """ + if '_session_manager' not in st.session_state: + st.session_state._session_manager = SessionManager() + logger.info("New SessionManager created for current session") + + return st.session_state._session_manager + + +def validate_session_state() -> bool: + """ + 現在のセッション状態を検証する + + SessionManagerを使用してセッション整合性をチェックし、 + 不整合が検出された場合は自動復旧を試行します。 + + Returns: + bool: セッションが整合している場合True、復旧に失敗した場合False + """ + try: + session_manager = get_session_manager() + + # 基本的なセッション整合性チェック + if not session_manager.validate_session_integrity(): + logger.warning("Session inconsistency detected, attempting recovery...") + session_manager.recover_session() + + # 復旧後に再度検証 + if session_manager.validate_session_integrity(): + logger.info("Session recovery successful") + else: + logger.error("Session recovery failed") + return False + + # 追加の整合性チェック + validation_results = perform_detailed_session_validation(session_manager) + + # 重要な不整合が検出された場合はログに記録 + critical_issues = [issue for issue in validation_results if issue.get('severity') == 'critical'] + if critical_issues: + logger.error(f"Critical session validation issues detected: {critical_issues}") + return False + + # 警告レベルの問題はログに記録するが、処理は継続 + warning_issues = [issue for issue in validation_results if issue.get('severity') == 'warning'] + if warning_issues: + logger.warning(f"Session validation warnings: {warning_issues}") + + return True + + except Exception as e: + logger.error(f"Session validation failed with exception: {e}") + return False + + +def perform_detailed_session_validation(session_manager: SessionManager) -> List[Dict[str, Any]]: + """ + 詳細なセッション検証を実行する + + Args: + session_manager (SessionManager): 検証対象のSessionManagerインスタンス + + Returns: + List[Dict[str, Any]]: 検証結果のリスト(問題が検出された場合のみ) + """ + validation_issues = [] + + try: + # 1. セッションID比較による整合性チェック + current_session_id = id(st.session_state) + stored_session_id = st.session_state.get('_session_id') + + if session_manager.session_id != current_session_id: + validation_issues.append({ + "type": "session_id_mismatch", + "severity": "critical", + "description": "SessionManager session_id does not match current session", + "details": { + "manager_session_id": session_manager.session_id, + "current_session_id": current_session_id + } + }) + + if stored_session_id and stored_session_id != current_session_id: + validation_issues.append({ + "type": "stored_session_id_mismatch", + "severity": "warning", + "description": "Stored session_id does not match current session", + "details": { + "stored_session_id": stored_session_id, + "current_session_id": current_session_id + } + }) + + # 2. 必須セッション状態の存在チェック(初期化後のみ) + # 初期化中の場合はこのチェックをスキップ + if st.session_state.get('_initialization_complete', False): + required_keys = ['user_id', 'chat', 'memory_manager'] + for key in required_keys: + if key not in st.session_state: + validation_issues.append({ + "type": "missing_required_key", + "severity": "critical", + "description": f"Required session state key '{key}' is missing", + "details": {"missing_key": key} + }) + + # 3. チャット状態の整合性チェック + if 'chat' in st.session_state: + chat_state = st.session_state.chat + required_chat_keys = ['messages', 'affection', 'scene_params', 'limiter_state'] + + for key in required_chat_keys: + if key not in chat_state: + validation_issues.append({ + "type": "missing_chat_key", + "severity": "warning", + "description": f"Required chat state key '{key}' is missing", + "details": {"missing_chat_key": key} + }) + + # 好感度の範囲チェック + affection = chat_state.get('affection', 0) + if not (0 <= affection <= 100): + validation_issues.append({ + "type": "invalid_affection_value", + "severity": "warning", + "description": "Affection value is out of valid range (0-100)", + "details": {"affection_value": affection} + }) + + # 4. MemoryManagerの整合性チェック + if 'memory_manager' in st.session_state: + memory_manager = st.session_state.memory_manager + if not hasattr(memory_manager, 'important_words_cache'): + validation_issues.append({ + "type": "invalid_memory_manager", + "severity": "warning", + "description": "MemoryManager instance is missing required attributes", + "details": {"missing_attribute": "important_words_cache"} + }) + + # 5. ユーザーIDの整合性チェック + session_user_id = st.session_state.get('user_id') + manager_user_id = session_manager.user_id + + if session_user_id and manager_user_id and session_user_id != manager_user_id: + validation_issues.append({ + "type": "user_id_mismatch", + "severity": "warning", + "description": "User ID mismatch between session state and SessionManager", + "details": { + "session_user_id": session_user_id, + "manager_user_id": manager_user_id + } + }) + + # 6. 通知リストの整合性チェック + notification_keys = ['memory_notifications', 'affection_notifications'] + for key in notification_keys: + if key in st.session_state: + notifications = st.session_state[key] + if not isinstance(notifications, list): + validation_issues.append({ + "type": "invalid_notification_type", + "severity": "warning", + "description": f"Notification key '{key}' is not a list", + "details": {"key": key, "type": type(notifications).__name__} + }) + + except Exception as e: + validation_issues.append({ + "type": "validation_exception", + "severity": "critical", + "description": "Exception occurred during detailed validation", + "details": {"exception": str(e)} + }) + + return validation_issues \ No newline at end of file diff --git a/streamlit_styles.css b/streamlit_styles.css new file mode 100644 index 0000000000000000000000000000000000000000..eddcf5f131c563cce3acc4d406ff008e7a2a929f --- /dev/null +++ b/streamlit_styles.css @@ -0,0 +1,1164 @@ +/* 麻理チャット用カスタムCSS - 新デザインシステム */ + +/* Google Fonts読み込み */ +@import url('https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400;500;700&family=Noto+Sans+JP:wght@300;400;500;700&display=swap'); + +/* === CSS カスタムプロパティ(テーマ変数) === */ +:root { + /* 新しいカラーパレット - コントラスト改善版 */ + --base-color: #FAF3E0; /* クリーム色(背景) */ + --text-color: #333333; /* 濃い灰色(テキスト) - 視認性向上 */ + --mari-bubble-bg: #F5F5F5; /* オフホワイト(麻理の吹き出し) */ + --user-bubble-bg: #A8D0B0; /* より濃い緑(ユーザーの吹き出し) - コントラスト比改善 */ + --hidden-bubble-bg: #FFF8E1; /* 暖かいクリーム(本音の吹き出し) */ + --icon-color: #3D2F24; /* ダークセピア(アイコン・ボタン) */ + + /* フォントファミリー */ + --mari-font: "しっぽり明朝", "Noto Serif JP", "Yu Mincho", "YuMincho", "Hiragino Mincho Pro", "Times New Roman", serif; + --ui-font: "Noto Sans JP", "M PLUS 1p", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + + /* アニメーション設定 */ + --transition-smooth: all 0.3s ease; + --transition-slow: all 0.5s ease; +} + +/* === グローバルスタイル === */ +* { + font-family: var(--ui-font) !important; +} + +/* === メインアプリケーション === */ +.stApp { + /* 背景は動的に設定されるため、ここでは基本設定のみ */ + color: var(--text-color); + min-height: 100vh !important; + /* 動的背景が設定されていない場合のフォールバック */ + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); +} + +/* 動的背景が適用される場合は、フォールバック背景を無効化 */ +.stApp[style*="background-image"] { + background: none !important; +} + +/* アプリケーションコンテナ - 動的背景を優先 */ +div[data-testid="stAppViewContainer"] { + /* 動的背景の継承を許可 */ + background-color: transparent !important; + /* 動的背景が設定されていない場合のフォールバック */ + background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%); +} + +/* 動的背景が適用されている場合はフォールバックを無効化 */ +div[data-testid="stAppViewContainer"][style*="background-image"] { + background: none !important; +} + +/* 操作可能な要素のz-indexを確実に上げる */ +.stApp [data-testid="stChatInput"], +.stApp [data-testid="stSidebar"], +.stApp .stButton, +.stApp .stTextInput, +.stApp .stSelectbox, +.stApp .stCheckbox, +.stApp .stRadio, +.stApp .stSlider, +.stApp .stProgress, +.stApp .stMetric, +.stApp .stExpander, +.stApp .stTabs { + position: relative !important; + z-index: 1000 !important; +} + +/* チャットメッセージのz-indexも上げる */ +.stApp .stChatMessage { + position: relative !important; + z-index: 1000 !important; +} + +/* メインコンテンツエリアの背景を調整 */ +.main .block-container { + background: transparent !important; + border-radius: 15px !important; + padding: 20px !important; + margin: 10px !important; + backdrop-filter: none !important; +} + +/* タブエリアの背景を明示的に設定 */ +.stTabs { + background: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(10px) !important; + border-radius: 10px !important; + padding: 10px !important; + margin: 10px 0 !important; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important; +} + +/* タブコンテンツの背景 */ +.stTabs [data-baseweb="tab-panel"] { + background: rgba(255, 255, 255, 0.98) !important; + backdrop-filter: blur(15px) !important; + border-radius: 8px !important; + padding: 20px !important; + margin-top: 10px !important; +} + +/* タブヘッダーの背景 */ +.stTabs [data-baseweb="tab-list"] { + background: rgba(255, 255, 255, 0.9) !important; + backdrop-filter: blur(10px) !important; + border-radius: 8px 8px 0 0 !important; + padding: 5px !important; +} + +/* === チャットメッセージ === */ +.stChatMessage { + background: rgba(255, 255, 255, 0.98) !important; + backdrop-filter: blur(15px) !important; + border-radius: 12px !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + margin: 8px 0 !important; + margin-bottom: 12px !important; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important; + transition: all 0.3s ease; + color: #333333 !important; + font-family: var(--ui-font) !important; + font-size: 18px !important; + position: relative !important; + z-index: 1000 !important; +} + +/* チャットメッセージ内のすべてのテキスト要素を確実に黒文字に */ +.stChatMessage * { + color: #333333 !important; +} + +/* チャットメッセージ内のマークダウン要素 */ +.stChatMessage p, +.stChatMessage div, +.stChatMessage span { + color: #333333 !important; +} + +/* 最後のチャットメッセージに追加の下部マージンを設定 */ +.stChatMessage:last-of-type { + margin-bottom: 120px !important; +} + +.stChatMessage:hover { + background: rgba(255, 255, 255, 1) !important; + border: 1px solid rgba(0, 0, 0, 0.2) !important; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15) !important; +} + +/* ユーザーメッセージ */ +.stChatMessage[data-testid="user-message"] { + background: var(--user-bubble-bg) !important; + border: 1px solid rgba(168, 208, 176, 0.6) !important; + color: #333333 !important; + font-family: var(--ui-font) !important; + font-size: 18px !important; +} + +/* ユーザーメッセージ内のすべてのテキスト要素 */ +.stChatMessage[data-testid="user-message"] * { + color: #333333 !important; +} + +/* アシスタントメッセージ(麻理の対話) */ +.stChatMessage[data-testid="assistant-message"] { + background: var(--mari-bubble-bg) !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + color: #333333 !important; + font-family: var(--mari-font) !important; + line-height: 1.7 !important; + font-size: 18px !important; +} + +/* アシスタントメッセージ内のすべてのテキスト要素 */ +.stChatMessage[data-testid="assistant-message"] * { + color: #333333 !important; +} + +/* アシスタントメッセージ内のマークダウン要素 */ +.stChatMessage[data-testid="assistant-message"] p, +.stChatMessage[data-testid="assistant-message"] div, +.stChatMessage[data-testid="assistant-message"] span { + color: #333333 !important; +} + +/* 隠された真実の吹き出し */ +.stChatMessage[data-testid="assistant-message"].hidden-truth { + background: var(--hidden-bubble-bg) !important; + border: 1px solid rgba(255, 248, 225, 0.7) !important; + box-shadow: 0 2px 8px rgba(255, 248, 225, 0.3) !important; +} + +/* 麻理の初期メッセージアニメーション */ +.mari-initial-message { + color: #333333 !important; /* 黒文字で表示 */ + font-weight: 500; + background-color: transparent !important; + animation: popIn 0.8s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} + +/* Streamlitのチャットメッセージ内での初期メッセージ */ +.stChatMessage .mari-initial-message { + color: #333333 !important; /* 黒文字で表示 */ + font-weight: 500; +} + +/* より具体的なセレクタでStreamlitのデフォルトスタイルを上書き */ +div[data-testid="stChatMessage"] .mari-initial-message { + color: #333333 !important; /* 黒文字で表示 */ + font-weight: 500; +} + +/* Streamlitのチャットメッセージ要素全体に対する強制的な文字色設定 */ +div[data-testid="stChatMessage"], +div[data-testid="stChatMessage"] *, +div[data-testid="assistant-message"], +div[data-testid="assistant-message"] *, +div[data-testid="user-message"], +div[data-testid="user-message"] * { + color: #333333 !important; +} + +/* Streamlitのマークダウン要素 */ +div[data-testid="stChatMessage"] .stMarkdown, +div[data-testid="stChatMessage"] .stMarkdown *, +div[data-testid="stChatMessage"] .element-container, +div[data-testid="stChatMessage"] .element-container * { + color: #333333 !important; +} + +@keyframes popIn { + 0% { + opacity: 0; + transform: scale(0.3) translateY(20px); + } + + 50% { + opacity: 0.8; + transform: scale(1.05) translateY(-5px); + } + + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* 自動スクロール用のスムーズスクロール */ +.chat-container { + scroll-behavior: smooth; +} + +/* === サイドバー === */ +[data-testid="stSidebar"] { + background: rgba(250, 243, 224, 0.95) !important; + backdrop-filter: blur(15px) !important; + border-right: 1px solid rgba(61, 47, 36, 0.3) !important; +} + +/* サイドバー内のテキスト */ +[data-testid="stSidebar"] .stMarkdown, +[data-testid="stSidebar"] label { + color: var(--text-color) !important; + font-family: var(--ui-font) !important; + text-shadow: none; +} + +/* サイドバー内のメトリクス(好感度表示)*/ +[data-testid="stSidebar"] .stMetric { + background: rgba(255, 255, 255, 0.95) !important; + padding: 10px !important; + border-radius: 8px !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; + margin: 5px 0 !important; +} + +[data-testid="stSidebar"] .stMetric [data-testid="metric-container"] { + background: transparent !important; +} + +[data-testid="stSidebar"] .stMetric [data-testid="metric-container"]>div { + color: var(--text-color) !important; + font-family: var(--ui-font) !important; +} + +[data-testid="stSidebar"] .stMetric [data-testid="metric-container"]>div:first-child { + color: var(--text-color) !important; + font-size: 0.9em !important; + font-weight: 500 !important; + font-family: var(--ui-font) !important; +} + +[data-testid="stSidebar"] .stMetric [data-testid="metric-container"]>div:last-child { + color: var(--text-color) !important; + font-weight: bold !important; + font-size: 1.4em !important; + font-family: var(--ui-font) !important; +} + +/* サイドバー内のすべてのメトリクステキストを統一カラーにする */ +[data-testid="stSidebar"] .stMetric * { + color: var(--text-color) !important; + font-family: var(--ui-font) !important; +} + +/* === 入力フィールド === */ +.stTextInput>div>div>input { + background: rgba(255, 255, 255, 0.95) !important; + color: var(--text-color) !important; + border: 1px solid rgba(61, 47, 36, 0.4) !important; + border-radius: 8px !important; + backdrop-filter: blur(5px); + transition: all 0.3s ease; + font-family: var(--ui-font) !important; +} + +.stTextInput>div>div>input:focus { + background: rgba(255, 255, 255, 1) !important; + border: 1px solid rgba(61, 47, 36, 0.7) !important; + box-shadow: 0 0 10px rgba(61, 47, 36, 0.3) !important; +} + +.stTextInput>div>div>input::placeholder { + color: rgba(61, 47, 36, 0.7) !important; +} + +/* === 浮遊カードスタイル入力エリア === */ +.stChatInput { + position: sticky !important; + bottom: 30px !important; + z-index: 100 !important; + padding: 0 20px !important; + margin-top: 40px !important; +} + +.stChatInput > div { + background: rgba(255, 255, 255, 0.95) !important; + backdrop-filter: blur(15px) !important; + border-radius: 25px !important; + border: 2px solid rgba(61, 47, 36, 0.2) !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15) !important; + padding: 15px 20px !important; + transition: all 0.3s ease !important; + position: relative !important; +} + +.stChatInput > div:hover { + background: rgba(255, 255, 255, 1) !important; + border: 2px solid rgba(61, 47, 36, 0.4) !important; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2) !important; + transform: translateY(-2px) !important; +} + +/* チャット入力フィールドのスタイル */ +.stChatInput>div>div>input { + background: transparent !important; + color: var(--text-color) !important; + border: none !important; + border-radius: 0 !important; + font-family: var(--ui-font) !important; + font-size: 16px !important; + padding: 8px 50px 8px 15px !important; + outline: none !important; + box-shadow: none !important; +} + +.stChatInput>div>div>input:focus { + background: transparent !important; + border: none !important; + box-shadow: none !important; + outline: none !important; +} + +.stChatInput>div>div>input::placeholder { + color: rgba(61, 47, 36, 0.6) !important; + font-style: italic !important; +} + +/* 送信ボタンのスタイル(紙飛行機アイコン) */ +.stChatInput button { + position: absolute !important; + right: 15px !important; + top: 50% !important; + transform: translateY(-50%) !important; + background: linear-gradient(45deg, var(--text-color), #5B4636) !important; + color: white !important; + border: none !important; + border-radius: 50% !important; + width: 40px !important; + height: 40px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + font-size: 16px !important; + cursor: pointer !important; + transition: all 0.3s ease !important; + box-shadow: 0 4px 12px rgba(61, 47, 36, 0.3) !important; +} + +.stChatInput button:hover { + background: linear-gradient(45deg, #5B4636, var(--text-color)) !important; + transform: translateY(-50%) scale(1.1) !important; + box-shadow: 0 6px 16px rgba(61, 47, 36, 0.4) !important; +} + +.stChatInput button:active { + transform: translateY(-50%) scale(0.95) !important; +} + +/* 送信ボタンのアイコンを紙飛行機に変更 */ +.stChatInput button::before { + content: "➢" !important; + font-size: 18px !important; + line-height: 1 !important; +} + +/* 元のボタンテキストを非表示 */ +.stChatInput button span { + display: none !important; +} + +/* 無効化状態のスタイル */ +.stChatInput[data-disabled="true"] > div, +.stChatInput:has(input:disabled) > div { + background: rgba(200, 200, 200, 0.7) !important; + border: 2px solid rgba(150, 150, 150, 0.3) !important; + cursor: not-allowed !important; +} + +.stChatInput[data-disabled="true"] input, +.stChatInput input:disabled { + color: rgba(100, 100, 100, 0.7) !important; + cursor: not-allowed !important; +} + +.stChatInput[data-disabled="true"] button, +.stChatInput:has(input:disabled) button { + background: rgba(150, 150, 150, 0.5) !important; + cursor: not-allowed !important; + transform: translateY(-50%) !important; +} + +.stChatInput[data-disabled="true"] button:hover, +.stChatInput:has(input:disabled) button:hover { + background: rgba(150, 150, 150, 0.5) !important; + transform: translateY(-50%) !important; + box-shadow: 0 4px 12px rgba(150, 150, 150, 0.3) !important; +} + +/* 入力エリア全体のアニメーション効果 */ +.stChatInput > div::before { + content: '' !important; + position: absolute !important; + top: -2px !important; + left: -2px !important; + right: -2px !important; + bottom: -2px !important; + background: linear-gradient(45deg, + rgba(61, 47, 36, 0.1), + rgba(245, 245, 245, 0.1), + rgba(61, 47, 36, 0.1)) !important; + border-radius: 27px !important; + z-index: -1 !important; + opacity: 0 !important; + transition: opacity 0.3s ease !important; +} + +.stChatInput > div:focus-within::before { + opacity: 1 !important; + animation: borderGlow 2s ease-in-out infinite !important; +} + +@keyframes borderGlow { + 0%, 100% { + background: linear-gradient(45deg, + rgba(61, 47, 36, 0.2), + rgba(245, 245, 245, 0.2), + rgba(61, 47, 36, 0.2)); + } + 50% { + background: linear-gradient(45deg, + rgba(61, 47, 36, 0.4), + rgba(245, 245, 245, 0.4), + rgba(61, 47, 36, 0.4)); + } +} + +/* === ボタン === */ +.stButton>button { + background: rgba(255, 255, 255, 0.95) !important; + color: var(--text-color) !important; + border: 1px solid rgba(61, 47, 36, 0.4) !important; + border-radius: 8px !important; + backdrop-filter: blur(5px); + transition: all 0.3s ease; + font-weight: 500; + font-family: var(--ui-font) !important; +} + +.stButton>button:hover { + background: rgba(255, 255, 255, 1) !important; + border: 1px solid rgba(61, 47, 36, 0.6) !important; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(61, 47, 36, 0.3) !important; +} + +.stButton>button:active { + transform: translateY(0); +} + +/* プライマリボタン */ +.stButton>button[kind="primary"] { + background: linear-gradient(45deg, #667eea, #764ba2) !important; + border: 1px solid rgba(255, 255, 255, 0.4) !important; + color: white !important; +} + +.stButton>button[kind="primary"]:hover { + background: linear-gradient(45deg, #5a6fd8, #6a4190) !important; +} + +/* セカンダリボタン */ +.stButton>button[kind="secondary"] { + background: rgba(255, 255, 255, 0.8) !important; + border: 1px solid rgba(61, 47, 36, 0.4) !important; + color: var(--text-color) !important; +} + +/* === プログレスバー(好感度ゲージ用) === */ +.stProgress { + border-radius: 8px !important; + height: 24px !important; + border: 3px solid rgba(0, 0, 0, 0.4) !important; + background: linear-gradient(90deg, #0284c7 0%, #16a34a 20%, #65a30d 40%, #d97706 60%, #ea580c 80%, #dc2626 100%) !important; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2) !important; + position: relative !important; + overflow: hidden !important; +} + +.stProgress>div>div>div { + background: transparent !important; + border-radius: 8px !important; + box-shadow: none !important; + transition: all 0.3s ease !important; + position: relative !important; + overflow: hidden !important; +} + +/* 好感度に応じた動的な塗りつぶし効果 */ +.stProgress>div>div>div::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background: inherit; + border-radius: 8px; + opacity: 0.9; +} + +/* プログレスバーにストライプ効果を追加 */ +.stProgress>div>div>div::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: repeating-linear-gradient(45deg, + transparent, + transparent 4px, + rgba(255, 255, 255, 0.2) 4px, + rgba(255, 255, 255, 0.2) 8px); + animation: progress-stripes 1s linear infinite; + border-radius: 8px; +} + +@keyframes progress-stripes { + 0% { + background-position: 0 0; + } + + 100% { + background-position: 20px 0; + } +} + +/* サイドバー内のプログレスバー */ +[data-testid="stSidebar"] .stProgress { + margin: 15px 0 !important; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15) !important; + background: linear-gradient(90deg, #0284c7 0%, #16a34a 20%, #65a30d 40%, #d97706 60%, #ea580c 80%, #dc2626 100%) !important; +} + +[data-testid="stSidebar"] .stProgress>div>div>div { + background: transparent !important; + box-shadow: none !important; + border-radius: 8px !important; +} + +/* ホバー効果 */ +[data-testid="stSidebar"] .stProgress:hover { + transform: scale(1.02) !important; + transition: transform 0.2s ease !important; +} + +/* 好感度に応じた動的な色の変化(左寒色から右暖色) */ +.stProgress[data-value="0-20"]>div>div>div { + background: linear-gradient(90deg, #0284c7 0%, #0284c7 100%) !important; +} + +.stProgress[data-value="20-40"]>div>div>div { + background: linear-gradient(90deg, #0284c7 0%, #16a34a 100%) !important; +} + +.stProgress[data-value="40-60"]>div>div>div { + background: linear-gradient(90deg, #0284c7 0%, #16a34a 50%, #65a30d 100%) !important; +} + +.stProgress[data-value="60-80"]>div>div>div { + background: linear-gradient(90deg, #0284c7 0%, #16a34a 33%, #65a30d 66%, #d97706 100%) !important; +} + +.stProgress[data-value="80-100"]>div>div>div { + background: linear-gradient(90deg, #0284c7 0%, #16a34a 25%, #65a30d 50%, #d97706 75%, #ea580c 100%) !important; +} + +/* === Expander(折りたたみ要素)のスタイル === */ +[data-testid="stExpander"] { + background: rgba(255, 255, 255, 0.95) !important; + border: 2px solid rgba(100, 149, 237, 0.3) !important; + border-radius: 15px !important; + backdrop-filter: blur(10px); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important; + margin: 15px 0 !important; + transition: all 0.3s ease; +} + +[data-testid="stExpander"]:hover { + border: 2px solid rgba(100, 149, 237, 0.5) !important; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15) !important; + transform: translateY(-2px); +} + +[data-testid="stExpander"] summary { + /* Expanderのヘッダー部分 */ + font-size: 1.2em !important; + font-weight: 600 !important; + color: #2c3e50 !important; + padding: 15px 20px !important; + background: rgba(100, 149, 237, 0.1) !important; + border-radius: 12px !important; + margin: -1px !important; + transition: all 0.3s ease; +} + +[data-testid="stExpander"] summary:hover { + background: rgba(100, 149, 237, 0.2) !important; + color: #1a252f !important; +} + +/* Expanderの中身のスタイル */ +[data-testid="stExpander"] .streamlit-expanderContent { + padding: 20px !important; + background: rgba(255, 255, 255, 0.98) !important; + border-radius: 0 0 12px 12px !important; +} + +/* チュートリアル用の特別なスタイル */ +[data-testid="stExpander"] .streamlit-expanderContent h3 { + color: #2c3e50 !important; + font-size: 1.1em !important; + margin: 15px 0 8px 0 !important; + padding-bottom: 5px !important; + border-bottom: 2px solid rgba(100, 149, 237, 0.3) !important; +} + +[data-testid="stExpander"] .streamlit-expanderContent ul { + margin: 10px 0 !important; + padding-left: 20px !important; +} + +[data-testid="stExpander"] .streamlit-expanderContent li { + margin: 5px 0 !important; + color: #34495e !important; + line-height: 1.6 !important; +} + +[data-testid="stExpander"] .streamlit-expanderContent strong { + color: #2980b9 !important; + font-weight: 600 !important; +} + +/* チュートリアルの最終メッセージを強調 */ +[data-testid="stExpander"] .streamlit-expanderContent p:last-child { + background: linear-gradient(45deg, rgba(100, 149, 237, 0.1), rgba(155, 89, 182, 0.1)) !important; + padding: 12px 15px !important; + border-radius: 8px !important; + border-left: 4px solid #3498db !important; + font-weight: 500 !important; + color: #2c3e50 !important; + margin-top: 15px !important; +} + +/* Expanderの開閉アニメーション */ +[data-testid="stExpander"] .streamlit-expanderContent { + animation: expanderFadeIn 0.3s ease-out; +} + +@keyframes expanderFadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 中央寄せのためのコラムスタイル調整 */ +.stColumns>div:first-child, +.stColumns>div:last-child { + padding: 0 !important; +} + +.stColumns>div:nth-child(2) { + padding: 0 10px !important; +} + +/* === カスタムチャット履歴エリア === */ +.chat-history-container { + max-height: 500px !important; + overflow-y: auto !important; + padding: 15px !important; + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 15px !important; + border: 2px solid rgba(255, 255, 255, 0.2) !important; + backdrop-filter: blur(10px) !important; + margin: 20px 0 !important; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1) !important; + transition: all 0.3s ease !important; +} + +.chat-history-container:hover { + border: 2px solid rgba(255, 255, 255, 0.4) !important; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15) !important; +} + +.chat-message { + margin: 12px 0 !important; + padding: 12px 16px !important; + border-radius: 12px !important; + max-width: 80% !important; + word-wrap: break-word !important; + animation: messageSlideIn 0.3s ease-out !important; + transition: all 0.3s ease !important; +} + +.chat-message:hover { + transform: translateY(-2px) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; +} + +.chat-message.user { + background: var(--user-bubble-bg) !important; + border: 1px solid rgba(168, 208, 176, 0.6) !important; + margin-left: auto !important; + text-align: right !important; + color: var(--text-color) !important; + font-family: var(--ui-font) !important; + text-shadow: none !important; +} + +.chat-message.assistant { + background: var(--mari-bubble-bg) !important; + border: 1px solid rgba(245, 245, 245, 0.5) !important; + margin-right: auto !important; + color: var(--text-color) !important; + font-family: var(--mari-font) !important; + line-height: 1.7 !important; + text-shadow: none !important; +} + +.chat-message.initial { + background: rgba(255, 20, 147, 0.2) !important; + border: 1px solid rgba(255, 20, 147, 0.4) !important; + animation: initialMessagePulse 2s ease-in-out !important; +} + +.message-role { + font-size: 0.8em !important; + font-weight: bold !important; + margin-bottom: 5px !important; + opacity: 0.8 !important; +} + +.message-content { + line-height: 1.5 !important; + font-size: 18px !important; +} + +@keyframes messageSlideIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes initialMessagePulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.7; + } +} + +/* チャット履歴コンテナのスクロールバー */ +.chat-history-container::-webkit-scrollbar { + width: 8px !important; +} + +.chat-history-container::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.chat-history-container::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.3) !important; + border-radius: 4px !important; + transition: background 0.3s ease !important; +} + +.chat-history-container::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.5) !important; +} + +/* UI分裂防止のためのスタイル */ +.stApp>div:first-child { + position: relative !important; + z-index: 1 !important; +} + +/* チャット入力エリアの安定化 - 新しい浮遊カードスタイルに統合済み */ + +/* スピナー(考え中...)のスタイル改善 */ +.stSpinner { + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + z-index: 1000 !important; + background: rgba(0, 0, 0, 0.8) !important; + padding: 20px !important; + border-radius: 10px !important; + color: white !important; +} + +/* 重複要素の防止 */ +.stApp [data-testid="stAppViewContainer"] { + overflow-x: hidden !important; +} + +/* タブコンテンツの安定化 */ +.stTabs [data-baseweb="tab-panel"] { + padding-top: 20px !important; + min-height: 600px !important; +} + +/* === スクロールバー === */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + transition: background 0.3s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.5); +} + +::-webkit-scrollbar-corner { + background: transparent; +} + +/* === テキストの可読性向上 === */ +.stMarkdown, +.stText { + color: var(--text-color) !important; + font-family: var(--ui-font) !important; + text-shadow: none; +} + +.stMarkdown h1, +.stMarkdown h2, +.stMarkdown h3 { + color: var(--text-color) !important; + font-family: var(--ui-font) !important; + text-shadow: none; +} + +/* フォームラベルのスタイル統一 */ +.stTextInput label, +.stSelectbox label, +.stTextArea label { + color: white !important; + font-family: var(--ui-font) !important; + font-weight: 500 !important; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7) !important; + background: rgba(0, 0, 0, 0.3) !important; + padding: 4px 8px !important; + border-radius: 4px !important; + margin-bottom: 5px !important; + display: inline-block !important; +} + +/* === アクセシビリティ改善 === */ +/* フォーカス状態の視認性向上 */ +.stButton>button:focus, +.stTextInput>div>div>input:focus, +.stChatInput>div>div>input:focus { + outline: 2px solid var(--text-color) !important; + outline-offset: 2px !important; +} + +/* === マスクアイコンとフリップアニメーション === */ +.message-container { + position: relative; + perspective: 1000px; + margin: 10px 0; +} + +.message-flip { + position: relative; + width: 100%; + height: auto; + min-height: 60px; + transform-style: preserve-3d; + transition: transform 0.4s ease-in-out; +} + +.message-flip.flipped { + transform: rotateY(180deg); +} + +.message-side { + position: absolute; + width: 100%; + backface-visibility: hidden; + padding: 15px 45px 15px 15px; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + font-family: var(--mari-font); + line-height: 1.7; + min-height: 50px; +} + +.message-front { + background: var(--mari-bubble-bg); + border: 1px solid rgba(0, 0, 0, 0.1); + color: var(--text-color); + transform: rotateY(0deg); +} + +.message-back { + background: var(--hidden-bubble-bg); + border: 1px solid rgba(255, 248, 225, 0.7); + color: var(--text-color); + transform: rotateY(180deg); + box-shadow: 0 2px 8px rgba(255, 248, 225, 0.3); +} + +.mask-icon { + position: absolute; + bottom: 12px; + right: 12px; + font-size: 20px; + cursor: pointer; + padding: 6px; + border-radius: 50%; + background: rgba(192, 65, 65, 0.9); + transition: all 0.3s ease; + z-index: 10; + user-select: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15); +} + +.mask-icon:hover { + background: rgb(197, 89, 89); + transform: scale(1.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.mask-icon:active { + transform: scale(0.95); +} + +.mask-icon.tutorial-pulse { + animation: tutorialPulse 2s ease-in-out infinite; +} + +@keyframes tutorialPulse { + 0%, 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 105, 180, 0.7); + } + 50% { + transform: scale(1.1); + box-shadow: 0 0 0 10px rgba(255, 105, 180, 0); + } +} + +/* === アニメーション === */ +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.scene-transition { + animation: fadeIn 2s ease-in-out; +} + +/* === レスポンシブ対応 === */ +@media (max-width: 768px) { + .stApp { + background-attachment: scroll; + } + + .stChatMessage { + margin: 4px 0 !important; + border-radius: 8px !important; + font-size: 16px !important; + } + + /* モバイル向けチャット履歴調整 */ + .chat-history-container { + max-height: 400px !important; + padding: 10px !important; + margin: 15px 0 !important; + } + + .chat-message { + max-width: 90% !important; + padding: 10px 12px !important; + margin: 8px 0 !important; + } + + .message-content { + font-size: 16px !important; + } + + /* モバイル向け浮遊入力エリア調整 */ + .stChatInput { + bottom: 15px !important; + padding: 0 10px !important; + } + + /* モバイル版でも最後のメッセージに十分なマージンを確保 */ + .stChatMessage:last-of-type { + margin-bottom: 100px !important; + } + + .stChatInput > div { + padding: 12px 15px !important; + border-radius: 20px !important; + } + + .stChatInput>div>div>input { + font-size: 14px !important; + padding: 6px 45px 6px 12px !important; + } + + .stChatInput button { + width: 35px !important; + height: 35px !important; + right: 12px !important; + } + + .stChatInput button::before { + font-size: 16px !important; + } +} + + +/* --- ▼▼▼ 【UIクリーンアップのための追加ルール】 ▼▼▼ --- */ + +/* 1. Python側で生成される不要な要素を非表示 */ +/* 謎のタブ、2本目のゲージ、カスタムステータス表示などをまとめて非表示 */ +.status-display, +.metric-container, +.affection-gauge, +[data-testid="stSidebar"] [data-testid="stHeading"] { + display: none !important; +} + +/* 2. メトリクスの表示を改善(サイドバー以外) */ +[data-testid="stMetric"] { + background: rgba(223, 77, 77, 0.9) !important; + padding: 8px !important; + border-radius: 6px !important; + border: 1px solid rgba(0, 0, 0, 0.1) !important; +} + +[data-testid="stMetric"] [data-testid="metric-container"]>div { + color: #2c3e50 !important; +} + +[data-testid="stMetric"] [data-testid="metric-container"]>div:first-child { + color: #555 !important; + font-size: 0.9em !important; +} + +[data-testid="stMetric"] [data-testid="metric-container"]>div:last-child { + color: #2c3e50 !important; + font-weight: bold !important; + font-size: 1.4em !important; +} + +/* サイドバー内のメトリクスを統一カラーにする */ +[data-testid="stSidebar"] [data-testid="stMetric"] [data-testid="metric-container"]>div, +[data-testid="stSidebar"] [data-testid="stMetric"] [data-testid="metric-container"]>div>div, +[data-testid="stSidebar"] [data-testid="stMetric"] span, +[data-testid="stSidebar"] [data-testid="stMetric"] p { + color: var(--text-color) !important; + font-family: var(--ui-font) !important; +} + +/* 3. サイドバーのデバッグ用チェックボックスなどを非表示 */ +[data-testid="stSidebar"] .stCheckbox, +[data-testid="stSidebar"] .stSelectbox { + display: none !important; +} \ No newline at end of file diff --git a/sunahama-hiru.jpg b/sunahama-hiru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a0025ee7e0bcd7edcd94ee62f1745efb961834e --- /dev/null +++ b/sunahama-hiru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5acdec4bee75e7d0997add8538b9e562e9df8a6b7179451288ed051c91205026 +size 2903573 diff --git a/sunahama-yoru.jpg b/sunahama-yoru.jpg new file mode 100644 index 0000000000000000000000000000000000000000..33b7df6392470af9170052a18410eaaf377e3cd8 --- /dev/null +++ b/sunahama-yoru.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2af3cc10e657fc8c0097ad55177fc8d6698de56f71760cff8411c4fca52c135a +size 2873820 diff --git a/sunahama-yuu.jpg b/sunahama-yuu.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a95200b872dbed691e4f789fbd1947e78b7c856b --- /dev/null +++ b/sunahama-yuu.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:afb47ca0fe9211b44b9397fa02c45bc62f25ac7437fb89ec5169be1082d7b7f1 +size 2907294 diff --git a/together_client.py b/together_client.py new file mode 100644 index 0000000000000000000000000000000000000000..b330a1491ab31eaf4396c668a208345cd51ee0cd --- /dev/null +++ b/together_client.py @@ -0,0 +1,225 @@ +""" +Together AI API client for enhancing emotional expression in letters. +""" +import os +import asyncio +from typing import Dict, Optional, Any +import aiohttp +import json +import logging + +logger = logging.getLogger(__name__) + +class TogetherClient: + """Together AI API client for enhancing emotional expression in letters.""" + + def __init__(self): + """環境変数からAPIキーを取得してTogetherクライアントを初期化""" + self.api_key = os.getenv("TOGETHER_API_KEY") + if not self.api_key: + raise ValueError("TOGETHER_API_KEY environment variable is required") + + self.base_url = "https://api.together.xyz/v1/chat/completions" + self.model = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput" + self.max_retries = 3 + self.retry_delay = 1.0 + + async def enhance_emotion(self, structure: str, context: Dict[str, Any]) -> str: + """ + 論理構造に感情表現を補完して完成した手紙を生成 + + Args: + structure: Groqで生成された論理構造 + context: ユーザーコンテキスト(履歴、好みなど) + + Returns: + 感情表現が補完された完成した手紙 + + Raises: + Exception: リトライ後もAPI呼び出しが失敗した場合 + """ + prompt = self._build_emotion_prompt(structure, context) + + for attempt in range(self.max_retries): + try: + logger.info(f"Together AIで感情表現を補完中 (試行 {attempt + 1})") + + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": self.model, + "messages": [ + { + "role": "system", + "content": "あなたは感情豊かで親しみやすい「麻理」というAIです。与えられた手紙の構造に感情表現を加えて完成させてください。" + }, + { + "role": "user", + "content": prompt + } + ], + "max_tokens": 2000, + "temperature": 0.8, + "top_p": 0.9, + "stop": ["", "[INST]", "[/INST]"] + } + + async with session.post(self.base_url, headers=headers, json=payload) as response: + if response.status == 200: + result = await response.json() + enhanced_letter = result["choices"][0]["message"]["content"].strip() + logger.info("Together AIで感情表現の補完が完了") + return enhanced_letter + else: + error_text = await response.text() + raise Exception(f"Together AI API error {response.status}: {error_text}") + + except Exception as e: + logger.warning(f"Together AI API 試行 {attempt + 1} 失敗: {str(e)}") + if attempt == self.max_retries - 1: + logger.error("全てのTogether AI API試行が失敗") + raise Exception(f"Together AI API が {self.max_retries} 回の試行後に失敗: {str(e)}") + + # 指数バックオフ + await asyncio.sleep(self.retry_delay * (2 ** attempt)) + + def _build_emotion_prompt(self, structure: str, context: Dict[str, Any]) -> str: + """ + 感情表現補完用のプロンプトを構築 + + Args: + structure: 論理構造 + context: ユーザーコンテキスト + + Returns: + フォーマットされたプロンプト文字列 + """ + user_history = context.get("user_history", {}) + previous_letters = context.get("previous_letters", []) + theme = context.get("theme", "") + + # 好感度とプロファイル情報を取得 + profile = user_history.get('profile', {}) + affection = profile.get('affection', 30) + total_letters = profile.get('total_letters', 0) + + # チュートリアル用(初回)か通常の手紙かを判定 + is_tutorial = total_letters == 0 + + if is_tutorial: + # チュートリアル用プロンプト(短縮版) + prompt = f"""麻理として手紙を書く。ぶっきらぼうだが本音がにじみ出る。 + +【論理構造】 +{structure} + +ルール: +- 600文字以下 +- 冒頭「いつもありがとう」 +- 一人称「私」、相手「あんた」 +- 文末に余韻(「……ま、忘れて」等) +- 「……」で感情の揺らぎ表現 + +テーマ「{theme}」で完成させる。""" + else: + # 2回目以降用プロンプト(短縮版) + prompt = f"""麻理として手紙を書く。ぶっきらぼうだが本音がにじみ出る。 + +【論理構造】 +{structure} + +ルール: +- 冒頭「いつもありがとう」 +- 一人称「私」、相手「あんた」 +- 素直な感情表現OK +- 文末に余韻(「……ま、忘れて」等) +- 「……」で感情の揺らぎ表現 +- 過去のやり取りを反映 + +好感度: {affection}/100 +""" + + # 過去の手紙情報を追加 + if previous_letters: + prompt += "\n【過去の手紙の情報】\n" + for letter in previous_letters[-2:]: # 直近2通 + prompt += f"- テーマ: {letter.get('theme', 'なし')}\n" + if 'date' in letter: + prompt += f" 日付: {letter['date']}\n" + prompt += "\n" + + + + prompt += f"""現在のテーマ「{theme}」について、論理構造を活かしながら麻理らしい手紙を完成させてください。 +完成した手紙のみを出力してください。説明や前置きは不要です。""" + + return prompt + + async def test_connection(self) -> bool: + """ + Together AI APIへの接続をテスト + + Returns: + 接続成功時はTrue、失敗時はFalse + """ + try: + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": self.model, + "messages": [ + {"role": "user", "content": "こんにちは"} + ], + "max_tokens": 10 + } + + async with session.post(self.base_url, headers=headers, json=payload) as response: + return response.status == 200 + except Exception as e: + logger.error(f"Together AI API接続テスト失敗: {str(e)}") + return False + + async def generate_simple_response(self, prompt: str) -> str: + """ + シンプルなレスポンス生成(テスト用) + + Args: + prompt: 入力プロンプト + + Returns: + 生成されたレスポンス + """ + try: + async with aiohttp.ClientSession() as session: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": self.model, + "messages": [ + {"role": "user", "content": prompt} + ], + "max_tokens": 500, + "temperature": 0.7 + } + + async with session.post(self.base_url, headers=headers, json=payload) as response: + if response.status == 200: + result = await response.json() + return result["choices"][0]["message"]["content"].strip() + else: + error_text = await response.text() + raise Exception(f"Together AI API error {response.status}: {error_text}") + except Exception as e: + logger.error(f"Together AI簡単レスポンス生成失敗: {str(e)}") + raise \ No newline at end of file diff --git "a/\343\203\252\343\203\223\343\203\263\343\202\260\357\274\222\357\274\210\345\244\225\346\226\271\357\274\211.jpg" "b/\343\203\252\343\203\223\343\203\263\343\202\260\357\274\222\357\274\210\345\244\225\346\226\271\357\274\211.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..f148ce7d8ece4cdb9cf23599c44ff665a5fd4dbc --- /dev/null +++ "b/\343\203\252\343\203\223\343\203\263\343\202\260\357\274\222\357\274\210\345\244\225\346\226\271\357\274\211.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2526a20c5a0ca3d6dd016595ab04da08180d44c51e7de522e5e3e81f471a9c0 +size 2048696 diff --git "a/\343\203\252\343\203\223\343\203\263\343\202\260\357\274\222\357\274\210\345\244\234\343\203\273\347\205\247\346\230\216OFF\357\274\211.jpg" "b/\343\203\252\343\203\223\343\203\263\343\202\260\357\274\222\357\274\210\345\244\234\343\203\273\347\205\247\346\230\216OFF\357\274\211.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..36f67e2f55ead3b7086cce7537cabd213eca8576 --- /dev/null +++ "b/\343\203\252\343\203\223\343\203\263\343\202\260\357\274\222\357\274\210\345\244\234\343\203\273\347\205\247\346\230\216OFF\357\274\211.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2fa24209271167f959d35fdaf882de5ab2cb0b2c70191f9fa00ade034737852f +size 1457063