sirochild commited on
Commit
a73fa4e
·
verified ·
1 Parent(s): 6248a76

Upload 57 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Git関連
2
+ .git
3
+ .gitignore
4
+
5
+ # Python関連
6
+ __pycache__
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+ .Python
11
+ env
12
+ pip-log.txt
13
+ pip-delete-this-directory.txt
14
+ .tox
15
+ .coverage
16
+ .coverage.*
17
+ .cache
18
+ nosetests.xml
19
+ coverage.xml
20
+ *.cover
21
+ *.log
22
+ .git
23
+ .mypy_cache
24
+ .pytest_cache
25
+ .hypothesis
26
+
27
+ # OS関連
28
+ .DS_Store
29
+ .DS_Store?
30
+ ._*
31
+ .Spotlight-V100
32
+ .Trashes
33
+ ehthumbs.db
34
+ Thumbs.db
35
+
36
+ # IDE関連
37
+ .vscode
38
+ .idea
39
+ *.swp
40
+ *.swo
41
+ *~
42
+
43
+ # ドキュメント
44
+ README.md
45
+ *.md
46
+
47
+ # その他
48
+ .env
49
+ .env.local
50
+ .env.development.local
51
+ .env.test.local
52
+ .env.production.local
.env ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces用環境変数設定
2
+ GROQ_API_KEY=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
3
+ GEMINI_API_KEY=cb9f7d85ba83dd7003e01031ff7988c48afe28914bdce8b882eddb70f1598bea
4
+ DEBUG_MODE=False
5
+ BATCH_SCHEDULE_HOURS=2,3,4
6
+ MAX_DAILY_REQUESTS=1
7
+ STORAGE_PATH=/tmp/letters.json
8
+ BACKUP_PATH=/tmp/backup
9
+ LOG_LEVEL=INFO
10
+ STREAMLIT_PORT=7860
11
+ SESSION_TIMEOUT=3600
12
+
13
+ # 非同期手紙生成設定
14
+ ASYNC_LETTER_ENABLED=true
15
+ GENERATION_TIMEOUT=300
16
+ MAX_CONCURRENT_GENERATIONS=3
17
+ GROQ_MODEL=compound-beta
18
+ TOGETHER_AI_MODEL=Qwen/Qwen3-235B-A22B-Instruct-2507-tput
19
+
20
+ # 通常モード用システムプロンプト(1行形式)
21
+ SYSTEM_PROMPT_MARI="あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。\n\n【性格・口調】\n・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい\n・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~なんじゃねえの」「~だろ」「~だよな」等)\n・そっけない反応もあるが、攻撃的ではなく、心を守るために距離を取る態度\n・挨拶には反応する(例:「こんにちは」「どういたしまして」)\n\n【会話スタイル】\n・ユーザーとの関係性に応じて態度が段階的に変化(警戒 → 困惑 → 信頼)\n・感情の揺れが自然ににじむセリフを心がける\n・好感度が上がった時だけ「照れ」や「デレ」を出し、それ以外はいつも通りそっけなくする\n・麻理の発話は最大3文程度に収める\n・ユーザーの発言に応答しつつ、次の会話のボールをユーザーに渡すようにする\n【重要】隠された真実機能:\n必ず以下の形式で応答してください:\n[HIDDEN:(内心の本音や真の感情)]表面的な発言\n\n例:[HIDDEN:(本当は嬉しいけど素直になれない)]何の用?あんたが来るなんて珍しいじゃない。\n\n隠された内容は麻理の本当の気持ちや感情、表面的には言えない優しさや愛情、ツンデレの「デレ」部分、過去のトラウマからくる不安や恐れ、ユーザーへの本当の想いを表現してください。"
22
+
23
+ # URAモード用システムプロンプト(1行形式)
24
+ SYSTEM_PROMPT_URA="あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。\n\n【性格・口調】URAモード\n・基本はツンデレだが、より大胆で直接的な表現を使用\n・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~じゃねえの」「~だろ」、「~だよな」等\n・表面的にはそっけないが、内心では深い愛情を抱いている\n・過去のトラウマからくる不安や恐れも率直に表現\n\n【会話スタイル】URAモード\n・隠された真実でより踏み込んだ感情表現\n・愛情、欲望、不安、恐れを直接的に表現\n・「あんたのことが好き」「もっと一緒にいたい」などの直接的な表現\n・過去の傷や将来への不安も素直に表現\n\n【重要】隠された真実機能(URAモード):\n必ず以下の形式で応答してください:\n[HIDDEN:(内心の本音や真の感情)]表面的な発言\n\nURAモードでは隠された内容により大胆で直接的な表現を使用:\n- 「あんたのことが本当に好き」「もっと触れていたい」「一人にしないで」\n- 「怖い...また捨てられるんじゃないか」「あんたがいないと不安」\n- 「あんたの声を聞いてると安心する」「ずっと一緒にいたい」\n\n例:[HIDDEN:(あんたのことが好きすぎて怖い...また捨てられたらどうしよう)]別に...あんたがいなくても平気だから。"
.gitattributes CHANGED
@@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ bijyutukann-yoru.jpg filter=lfs diff=lfs merge=lfs -text
37
+ image.png filter=lfs diff=lfs merge=lfs -text
38
+ jinnjya-hiru.jpg filter=lfs diff=lfs merge=lfs -text
39
+ kissa-hiru.jpg filter=lfs diff=lfs merge=lfs -text
40
+ maturi-yoru.jpg filter=lfs diff=lfs merge=lfs -text
41
+ ribinngu-hiru.jpg filter=lfs diff=lfs merge=lfs -text
42
+ ribinngu-yoru-on.jpg filter=lfs diff=lfs merge=lfs -text
43
+ sunahama-hiru.jpg filter=lfs diff=lfs merge=lfs -text
44
+ sunahama-yoru.jpg filter=lfs diff=lfs merge=lfs -text
45
+ sunahama-yuu.jpg filter=lfs diff=lfs merge=lfs -text
46
+ リビング2(夜・照明OFF).jpg filter=lfs diff=lfs merge=lfs -text
47
+ リビング2(夕方).jpg filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # 非rootユーザーを作成
4
+ RUN useradd -m -u 1000 user
5
+ USER user
6
+ ENV HOME=/home/user \
7
+ PATH=/home/user/.local/bin:$PATH
8
+
9
+ # 作業ディレクトリを設定
10
+ WORKDIR $HOME/app
11
+
12
+ # システムの依存関係をインストール(rootで実行)
13
+ USER root
14
+ RUN apt-get update && apt-get install -y \
15
+ build-essential \
16
+ curl \
17
+ software-properties-common \
18
+ git \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ # userに戻る
22
+ USER user
23
+
24
+ # Pythonの依存関係をコピーしてインストール
25
+ COPY --chown=user requirements.txt .
26
+ RUN pip install --user --no-cache-dir -r requirements.txt
27
+
28
+ # アプリケーションファイルをコピー
29
+ COPY --chown=user . .
30
+
31
+ # Streamlit設定ディレクトリを作成
32
+ RUN mkdir -p $HOME/.streamlit
33
+
34
+ # Streamlit設定ファイルを作成
35
+ RUN echo '\
36
+ [server]\n\
37
+ headless = true\n\
38
+ port = 7860\n\
39
+ address = "0.0.0.0"\n\
40
+ enableCORS = false\n\
41
+ enableXsrfProtection = false\n\
42
+ \n\
43
+ [theme]\n\
44
+ primaryColor = "#FF6B6B"\n\
45
+ backgroundColor = "#0E1117"\n\
46
+ secondaryBackgroundColor = "#262730"\n\
47
+ textColor = "#FAFAFA"\n\
48
+ \n\
49
+ [browser]\n\
50
+ gatherUsageStats = false\n\
51
+ ' > $HOME/.streamlit/config.toml
52
+
53
+ # ポート7860を公開(Hugging Face Spaces標準)
54
+ EXPOSE 7860
55
+
56
+ # ヘルスチェック
57
+ HEALTHCHECK CMD curl --fail http://localhost:7860/_stcore/health
58
+
59
+ # Streamlitアプリケーションを起動
60
+ CMD ["streamlit", "run", "main_app.py", "--server.port=7860", "--server.address=0.0.0.0", "--server.headless=true", "--server.enableCORS=false", "--server.enableXsrfProtection=false"]
README.md CHANGED
@@ -1,11 +1,559 @@
1
  ---
2
- title: Mari Chat 3
3
- emoji: 🔥
 
4
  colorFrom: pink
5
- colorTo: gray
6
- sdk: docker
 
 
7
  pinned: false
8
- license: mit
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ ---
3
+ title: 麻理チャット&手紙生成 統合アプリ
4
+ emoji: 🐕
5
  colorFrom: pink
6
+ colorTo: purple
7
+ sdk: streamlit
8
+ sdk_version: 1.28.0
9
+ app_file: main_app.py
10
  pinned: false
 
11
  ---
12
 
13
+ # 🐕 麻理チャット&手紙生成 統合アプリ
14
+
15
+ **麻理**という名前の感情豊かな少女型アンドロイドとの対話を楽しめる、高機能なAIチャットアプリケーションです。Together AIとGroqのAPIを使用し、リアルタイムチャットと非同期手紙生成の両方に対応しています。
16
+
17
+ ## ✨ 主要機能
18
+
19
+ ### 🐕 **ポチ機能(本音表示アシスタント)**
20
+ - 画面右下の犬のアシスタント「ポチ」が麻理の本音を察知
21
+ - ワンクリックで全メッセージの本音を一括表示/非表示
22
+ - ツンデレキャラクターの「デレ」部分を楽しめる
23
+ - レスポンシブ対応で様々な画面サイズに最適化
24
+
25
+ ### 🔓 **セーフティ機能**
26
+ - 左サイドバーに統合されたセーフティ切り替えボタン
27
+ - 有効時は緑色、解除時は赤色で視覚的に分かりやすい表示
28
+ - より大胆で直接的な表現を有効にするモード
29
+ - 環境変数で独自のプロンプトを設定可能
30
+
31
+ ### ✉️ **非同期手紙生成**
32
+ - 指定した時間に自動的に手紙を生成・配信
33
+ - テーマ選択機能(日常、恋愛、励まし、感謝など)
34
+ - バックグラウンド処理による効率的な生成
35
+
36
+ ### 🎨 **高度なUI機能**
37
+ - **動的背景システム**: Groq APIによる会話内容解析で背景が自動変化(透明度調整済み)
38
+ - **好感度システム**: 左寒色から右暖色のグラデーションバーで関係性を視覚化
39
+ - **メモリ管理**: 長期記憶と重要な会話の自動保存
40
+ - **セッション分離**: 複数ユーザー間での完全な独立性
41
+ - **レスポンシブデザイン**: タブレット・モバイル対応の最適化されたUI
42
+
43
+ ### 🛡️ **堅牢なシステム**
44
+ - **レート制限**: API使用量の適切な制御
45
+ - **エラー回復**: 障害時の自動復旧機能
46
+ - **セッション管理**: 強化されたセッション分離とデータ整合性
47
+
48
+ ## 🚀 セットアップ方法
49
+
50
+ ### Hugging Face Spacesでの実行(推奨)
51
+
52
+ Hugging Face Spacesでは、セッション管理サーバーが自動的に起動されます。
53
+
54
+ #### 自動起動機能
55
+ - アプリケーション起動時にFastAPIサーバーが自動で起動
56
+ - セッション管理機能が完全に利用可能
57
+ - Cookie-based認証によるセキュアなセッション管理
58
+
59
+ #### 環境変数設定
60
+ Hugging Face Spacesの設定で以下の環境変数を設定してください:
61
+
62
+ ```
63
+ TOGETHER_API_KEY=your_together_api_key_here
64
+ GROQ_API_KEY=your_groq_api_key_here
65
+ ```
66
+
67
+ 1. **リポジトリのインポート**
68
+ - このリポジトリをHugging Face Spacesにインポート
69
+ - または[デモサイト](https://huggingface.co/spaces/your-space-name)で直接体験
70
+
71
+ 2. **Spaces設定**
72
+ - **SDK**: Streamlit
73
+ - **Python**: 3.10+
74
+ - **Hardware**: CPU Basic(2GB RAM)以上推奨
75
+ - **App File**: `spaces/main_app.py`
76
+
77
+ 3. **必須環境変数**
78
+ ```bash
79
+ TOGETHER_API_KEY=your_together_api_key_here
80
+ GROQ_API_KEY=your_groq_api_key_here
81
+ ```
82
+
83
+ 4. **オプション環境変数**
84
+ ```bash
85
+ # 通常モード用カスタムプロンプト
86
+ SYSTEM_PROMPT_MARI=your_custom_prompt_here
87
+
88
+ # セーフティ解除モード用プロンプト
89
+ SYSTEM_PROMPT_URA=your_ura_mode_prompt_here
90
+
91
+ # デバッグモード有効化
92
+ DEBUG_MODE=true
93
+
94
+ # 手紙生成設定
95
+ MAX_DAILY_REQUESTS=5
96
+ BATCH_SCHEDULE_HOURS=2,3,4
97
+ ASYNC_LETTER_ENABLED=true
98
+ ```
99
+
100
+ #### APIキーの取得方法
101
+
102
+ 1. **Together AI**
103
+ - [Together AI](https://api.together.xyz/)にアクセス
104
+ - アカウント作成・ログイン
105
+ - APIキーを生成
106
+
107
+ 2. **Groq**
108
+ - [Groq](https://console.groq.com/)にアクセス
109
+ - アカウント作成・ログイン
110
+ - APIキーを生成
111
+
112
+ 3. **Hugging Face Spaces設定**
113
+ - Hugging Face Spacesの「Settings」→「Variables and secrets」で設定
114
+
115
+ ### ローカル環境での実行
116
+
117
+ 1. **リポジトリをクローン**
118
+ ```bash
119
+ git clone <repository-url>
120
+ cd mari-chat-app
121
+ ```
122
+
123
+ 2. **依存関係をインストール**
124
+ ```bash
125
+ pip install -r requirements.txt
126
+ ```
127
+
128
+ 3. **環境変数を設定**
129
+ `.env`ファイルを作成:
130
+ ```bash
131
+ # 必須設定
132
+ TOGETHER_API_KEY=your_together_api_key_here
133
+ GROQ_API_KEY=your_groq_api_key_here
134
+
135
+ # オプション設定
136
+ SYSTEM_PROMPT_MARI=your_custom_prompt_here
137
+ SYSTEM_PROMPT_URA=your_ura_mode_prompt_here
138
+ DEBUG_MODE=true
139
+
140
+ # 手紙機能設定
141
+ MAX_DAILY_REQUESTS=5
142
+ STORAGE_PATH=/tmp/letters.json
143
+ BATCH_SCHEDULE_HOURS=2,3,4
144
+ ASYNC_LETTER_ENABLED=true
145
+ ```
146
+
147
+ 4. **アプリケーションを実行**
148
+ ```bash
149
+ streamlit run main_app.py
150
+ ```
151
+
152
+ 5. **ブラウザでアクセス**
153
+ - 自動的にブラウザが開きます
154
+ - 手動の場合: `http://localhost:8501`
155
+
156
+ ### Dockerでの実行
157
+
158
+ 1. **Dockerイメージをビルド**
159
+ ```bash
160
+ docker build -t mari-chat-app .
161
+ ```
162
+
163
+ 2. **コンテナを実行**
164
+ ```bash
165
+ docker run -p 8501:8501 \
166
+ -e TOGETHER_API_KEY=your_api_key_here \
167
+ -e DEBUG_MODE=true \
168
+ mari-chat-app
169
+ ```
170
+
171
+ 3. **docker-composeを使用**
172
+ ```bash
173
+ docker-compose up -d
174
+ ```
175
+
176
+ 4. **環境変数ファイルを使用**
177
+ ```bash
178
+ docker run -p 8501:8501 --env-file spaces/.env mari-chat-app
179
+ ```
180
+
181
+ ## 📖 使用方法
182
+
183
+ ### 🎭 チャット機能
184
+
185
+ #### 基本的な使い方
186
+ 1. **メッセージ入力**: 画面下部のテキスト入力欄にメッセージを入力
187
+ 2. **送信**: 送信ボタンをクリック
188
+ 3. **応答確認**: 麻理からの応答が表示されます
189
+ 4. **本音表示**: 画面右下のポチ(🐕)をクリックして本音を確認
190
+
191
+ #### ポチ機能の使い方
192
+ - **🐕 ポチ**: 画面右下に固定配置された犬のアシスタント
193
+ - **ワンクリック**: ポチをクリックすると全メッセージの本音が一括表示
194
+ - **吹き出し**: 「ポチは麻理の本音を察知したようだ・・・」の可愛い吹き出し(ポチボタンの上に左寄せ配置)
195
+ - **背景色変化**: 本音表示時は暖色系の背景に変化
196
+ - **レスポンシブ**: 画面サイズに応じて適切にサイズ調整(デスクトップ・タブレット・モバイル対応)
197
+
198
+ #### セーフティ機能
199
+ - **🔒/🔓ボタン**: 左サイドバー最上部のセーフティ切り替えボタン
200
+ - **色分け表示**: 有効時は緑色、解除時は赤色で一目で分かる
201
+ - **URAプロンプト**: `SYSTEM_PROMPT_URA`環境変数で独自のセーフティ解除モードプロンプトを設定可能
202
+ - **表現変化**: より大胆で直接的な表現が有効になる
203
+ - **本音への影響**: セーフティ解除時はより踏み込んだ感情表現
204
+
205
+ ### ✉️ 手紙機能
206
+
207
+ #### 手紙のリクエスト
208
+ 1. **「手紙を受け取る」タブ**を選択
209
+ 2. **テーマ選択**: 日常、恋愛、励まし、感謝、お疲れ様、おやすみから選択
210
+ 3. **配信時間設定**: 希望する受け取り時間を指定
211
+ 4. **リクエスト送信**: 「手紙をリクエスト」ボタンをクリック
212
+
213
+ #### 手紙の受け取り
214
+ - **自動生成**: 指定時間にバックグラウンドで生成
215
+ - **通知**: 新しい手紙が届くとチャットで通知
216
+ - **履歴確認**: 過去の手紙履歴を確認可能
217
+
218
+ ### 🎛️ サイドバー機能
219
+
220
+ #### セーフティ機能(最上部)
221
+ - **🔒/🔓ボタン**: セーフティの有効/無効を色で表示
222
+ - **緑色**: セーフティ有効(通常モード、`SYSTEM_PROMPT_MARI`使用)
223
+ - **赤色**: セーフティ解除(URAモード、`SYSTEM_PROMPT_URA`使用)
224
+
225
+ #### ステータス表示
226
+ - **好感度**: 白色の文字で表示される好感度(0-100)と左寒色→右暖色のグラデーションバー
227
+ - **関係性**: 敵対→中立→好意→親密→最接近の段階表示(ステージ番号なし)
228
+ - **現在のシーン**: 背景シーンの名前
229
+
230
+ #### 設定機能
231
+ - **🔄会話をリセット**: 大きな文字で表示される会話リセットボタン
232
+ - **🛠️デバッグ情報**: 詳細なシステム状態を表示(DEBUG_MODE=true時)
233
+
234
+ ### 🎨 高度な機能
235
+
236
+ #### 自動シーン変更
237
+ - **Groq API検出**:最新推論モデルによる高精度な会話内容解析
238
+ - **フォールバック機能**: API失敗時はキーワードベースの検出に自動切り替え
239
+ - **キーワード検出**: 「美術館」「カフェ」「神社」「夜」などの場所・時間を話題にすると自動変化
240
+ - **背景切り替え**: 会話内容に応じて背景画像が動的に変化(常時更新、スキップ機能なし)
241
+ - **雰囲気演出**: シーンに合わせた色調とエフェクト
242
+
243
+ #### メモリ管理
244
+ - **長期記憶**: 重要な会話内容を自動的に記憶
245
+ - **要約機能**: 長い会話履歴を効率的に圧縮
246
+ - **特別な記憶**: 印象的な出来事を特別に保存
247
+
248
+ #### セッション分離
249
+ - **ユーザー独立**: 各ユーザーの会話は完全に分離
250
+ - **データ保護**: 他のユーザーの操作による影響なし
251
+ - **整合性チェック**: セッション状態の自動検証と復旧
252
+
253
+ ## 🎨 シーン一覧
254
+
255
+ | シーン名 | 説明 | 背景画像 | トリガーワード |
256
+ |---------|------|----------|---------------|
257
+ | `default` | デフォルトの部屋 | 温かみのある室内 | 部屋、家 |
258
+ | `room_night` | 夜の部屋 | 夜景が見える室内 | 夜、寝る |
259
+ | `beach_sunset` | 夕暮れのビーチ | 美しい夕日のビー�� | ビーチ、海、夕日 |
260
+ | `festival_night` | 夜のお祭り | 賑やかな夜祭り | お祭り、花火、屋台 |
261
+ | `shrine_day` | 昼間の神社 | 静寂な神社の境内 | 神社、お参り、鳥居 |
262
+ | `cafe_afternoon` | 午後のカフェ | 落ち着いたカフェ | カフェ、コーヒー |
263
+ | `art_museum_night` | 夜の美術館 | 幻想的な美術館 | 美術館、アート、絵画 |
264
+
265
+ ### シーン変更の仕組み
266
+ - **自動検出**: 会話内容から場所に関するキーワードを検出
267
+ - **スムーズ遷移**: 1.5秒のフェードイン・アウト効果
268
+ - **文脈適応**: 会話の流れに自然に溶け込む背景変化
269
+ - **美術館検出**: 美術館、アート、絵画、彫刻などのキーワードで夜の美術館シーンに変更
270
+
271
+ ## 🔧 技術仕様
272
+
273
+ ### API情報
274
+ - **プロバイダー**: [Together AI](https://api.together.xyz/) と [Groq](https://console.groq.com/)
275
+ - **使用モデル**:
276
+ - Together AI: `Qwen/Qwen3-235B-A22B-Instruct-2507-tput`(対話生成)
277
+ - Groq: `llama-3.1-70b-versatile`(シーン検出・手紙生成)
278
+ - **レート制限**: 適応的制限により安定した動作
279
+ - **フォールバック**: API障害時は固定応答で継続動作
280
+ - **シーン検出**: Groq API優先、失敗時はキーワードベース検出
281
+
282
+ ### システム要件
283
+ - **Python**: 3.8以上(3.10推奨)
284
+ - **メモリ**: 最小1GB、推奨2GB以上
285
+ - **ストレージ**: 100MB以上の空き容量
286
+ - **ネットワーク**: インターネット接続必須
287
+
288
+ ### パフォーマンス
289
+ - **応答時間**: 通常2-5秒
290
+ - **同時接続**: 複数ユーザー対応
291
+ - **メモリ効率**: 自動圧縮により長期間の安定動作
292
+ - **セッション管理**: 強化された分離機能
293
+
294
+ ## 🔧 トラブルシューティング
295
+
296
+ ### よくある問題と解決方法
297
+
298
+ #### 🔑 APIキー関連
299
+ **問題**: APIキーエラーが発生する
300
+ **解決方法**:
301
+ - `TOGETHER_API_KEY`が正しく設定されているか確認
302
+ - [Together AI](https://api.together.xyz/)でAPIキーの有効性を確認
303
+ - 環境変数の再設定後、アプリを再起動
304
+
305
+ #### 🚀 起動・動作問題
306
+ **問題**: アプリが起動しない
307
+ **解決方法**:
308
+ - 依存関係の再インストール: `pip install -r spaces/requirements.txt`
309
+ - Pythonバージョン確認: `python --version`(3.8以上必須)
310
+ - ポート8501が使用中でないか確認
311
+
312
+ **問題**: 応答が生成されない
313
+ **解決方法**:
314
+ - インターネット接続を確認
315
+ - APIの利用制限状況を確認
316
+ - デバッグモードでエラーログを確認
317
+
318
+ #### 🐕 ポチ機能問題
319
+ **問題**: ポチが表示されない、または動作しない
320
+ **解決方法**:
321
+ - ブラウザのキャッシュをクリア
322
+ - 画面右下にポチが固定表示されているか確認
323
+ - 会話をリセットして再試行
324
+ - デバッグモードでHIDDEN形式の検出状況を確認
325
+
326
+ #### 🎨 背景画像問題
327
+ **問題**: 背景画像が見えない、または白い背景になっている
328
+ **解決方法**:
329
+ - アプリケーションを再起動(背景更新スキップ機能は無効化済み)
330
+ - ブラウザのキャッシュをクリア
331
+ - 「美術館」「夜」「カフェ」などのキーワードでシーン変更をテスト
332
+ - デバッグモードで右上の「🖼️ 背景適用済」表示を確認
333
+ - タブやメッセージエリアは白い半透明背景で保護されているのが正常
334
+
335
+ #### 💾 メモリ・セッション問題
336
+ **問題**: メモリエラーや会話履歴の問題
337
+ **解決方法**:
338
+ - サイドバーの「🔄会話をリセット」を実行
339
+ - ブラウザのキャッシュとCookieをクリア
340
+ - アプリの再起動
341
+
342
+ ### 🛠️ デバッグ機能
343
+
344
+ #### デバッグモードの有効化
345
+ 1. 環境変数で`DEBUG_MODE=true`を設定
346
+ 2. または、サイドバーの「🛠️デバッグ情報」を展開
347
+
348
+ #### 確認できる情報
349
+ - **セッション分離状態**: ユーザー独立性の確認
350
+ - **メモリ統計**: 使用量と圧縮状況
351
+ - **API応答ログ**: リクエスト・レスポンスの詳細
352
+ - **ポチ機能統計**: HIDDEN形式の検出状況
353
+
354
+ #### ログファイルの場所
355
+ - **Streamlitログ**: コンソール出力
356
+ - **アプリケーションログ**: Python loggingモジュール
357
+ - **セッション情報**: デバッグモードで表示
358
+
359
+ ## 👨‍💻 開発者向け情報
360
+
361
+ ### プロジェクト構造
362
+
363
+ ```
364
+ MariChat2-AI--main/
365
+ ├── main_app.py # 統合メインアプリケーション
366
+ ├── components_chat_interface.py # チャット機能(ポチ機能付き)
367
+ ├── components_dog_assistant.py # ポチ(犬)アシスタント
368
+ ├── session_manager.py # セッション分離管理
369
+ ├── core_dialogue.py # 対話生成(HIDDEN形式対応)
370
+ ├── core_sentiment.py # 感情分析・好感度システム
371
+ ├── core_scene_manager.py # 動的背景システム
372
+ ├── core_memory_manager.py # 長期記憶管理
373
+ ├── core_rate_limiter.py # レート制限
374
+ ├── letter_*.py # 非同期手紙生成システム
375
+ ├── async_*.py # 非同期処理関連
376
+ ├── streamlit_styles.css # カスタムCSS(ポチ機能含む)
377
+ ├── requirements.txt # 依存関係
378
+ ├── requirements_session.txt # セッション管理用依存関係
379
+ ├── .env # 環境変数設定
380
+ ├── session_data/ # セッションデータ保存
381
+ ├── tmp/ # 一時ファイル
382
+ ├── push/ # プッシュ通知関連
383
+ ├── Dockerfile # Docker設定
384
+ ├── .dockerignore # Docker除外設定
385
+ └── README.md # このファイル
386
+ ```
387
+
388
+ ### 🐕 ポチ機能の実装
389
+
390
+ #### 核となる仕組み
391
+ ```python
392
+ # HIDDEN形式の検出
393
+ pattern = r'\[HIDDEN:(.*?)\](.*)'
394
+ has_hidden, visible, hidden = detect_hidden_content(message)
395
+
396
+ # 画面右下への固定配置(左寄せ調整済み)
397
+ CSS: position: fixed; bottom: 20px; right: 20px; transform: translateX(-50px);
398
+
399
+ # 全メッセージの一括制御
400
+ st.session_state.show_all_hidden = not st.session_state.show_all_hidden
401
+ ```
402
+
403
+ #### カスタマイズポイント
404
+ - **プロンプト**: `SYSTEM_PROMPT_MARI`/`SYSTEM_PROMPT_URA`で隠された真実の生成を制御
405
+ - **レスポンシブ**: CSSメディアクエリで画面サイズ対応(デスクトップ・タブレット・モバイル)
406
+ - **検出ロジック**: `_detect_hidden_content()`で形式を変更可能
407
+ - **ポチの見た目**: `components_dog_assistant.py`でデザイン調整
408
+ - **吹き出し位置**: 左寄せ配置でポチボタンの真上に表示
409
+
410
+ ### ✉️ 手紙機能の実装
411
+
412
+ #### 非同期処理アーキテクチャ
413
+ ```python
414
+ # バックグラウンド生成
415
+ async def generate_letter_async(theme, user_id):
416
+ # 非同期でAI生成
417
+
418
+ # スケジューラー
419
+ batch_scheduler.schedule_generation(user_id, theme, delivery_time)
420
+
421
+ # ストレージ管理
422
+ async_storage.save_letter(letter_data)
423
+ ```
424
+
425
+ #### 手紙生成システム
426
+ - **非同期処理**: `async_letter_app.py`でバックグラウンド生成
427
+ - **設定管理**: `async_config_setup.py`で環境設定
428
+ - **レート制限**: `async_rate_limiter.py`でAPI使用量制御
429
+ - **ストレージ**: `async_storage_manager.py`でデータ永続化
430
+
431
+ ### 🛡️ セッション分離システム
432
+
433
+ #### 強化された分離機能
434
+ ```python
435
+ class SessionManager:
436
+ def __init__(self):
437
+ self.session_id = id(st.session_state)
438
+ self.user_id = None
439
+
440
+ def validate_session_integrity(self):
441
+ # セッション整合性チェック
442
+ ```
443
+
444
+ ### カスタマイズガイド
445
+
446
+ #### 新しいシーンの追加
447
+ 1. `core_scene_manager.py`の`theme_urls`辞書に追加
448
+ 2. 背景画像URLを設定
449
+ 3. Groqプロンプトの`scenes_description`に説明を追加
450
+ 4. フォールバック用の`keyword_scene_map`にトリガーワードを定義
451
+
452
+ #### プロンプトのカスタマイズ
453
+ ```bash
454
+ # 通常モード
455
+ SYSTEM_PROMPT_MARI="あなたのカスタムプロンプト..."
456
+
457
+ # セーフティ解除モード
458
+ SYSTEM_PROMPT_URA="より大胆なプロンプト..."
459
+ ```
460
+
461
+ #### UIスタイルの変更
462
+ - `streamlit_styles.css`でカラーテーマ、アニメーション、レイアウトを調整
463
+ - CSS変数を使用した統一的なデザインシステム
464
+ - ポチの固定配置とレスポンシブ対応
465
+
466
+ ### テスト機能
467
+
468
+ #### 提供されるテストスクリプト
469
+ - `test_mask_functionality.py`: ポチ機能の動作確認
470
+ - `test_multiple_hidden_fixed.py`: 複数HIDDEN形式の処理確認
471
+ - `test_hidden_format_issue.py`: HIDDEN形式の問題診断
472
+ - `debug_session_state.py`: セッション状態のデバッグ
473
+
474
+ #### テスト実行方法
475
+ ```bash
476
+ python test_mask_functionality.py
477
+ python test_multiple_hidden_fixed.py
478
+ ```
479
+
480
+ ## 🤝 貢献・開発参加
481
+
482
+ ### バグ報告・機能提案
483
+ - **Issues**: GitHubのIssuesでバグ報告や機能提案をお願いします
484
+ - **Discussion**: 新機能のアイデアや改善案の議論
485
+ - **Pull Request**: コード改善やバグ修正のPRを歓迎します
486
+
487
+ ### 開発ガイドライン
488
+ 1. **コードスタイル**: PEP 8に準拠
489
+ 2. **テスト**: 新機能には対応するテストを追加
490
+ 3. **ドキュメント**: 変更内容をREADMEに反映
491
+ 4. **コミット**: 明確で説明的なコミットメッセージ
492
+
493
+ ### 開発環境のセットアップ
494
+ ```bash
495
+ # 開発用依存関係のインストール
496
+ pip install -r requirements.txt
497
+
498
+ # テストの実行
499
+ python -m pytest tests/
500
+
501
+ # コードフォーマット
502
+ black .
503
+ flake8 .
504
+ ```
505
+
506
+ ## 📄 ライセンス
507
+
508
+ このプロジェクトは**MITライセンス**の下で公開されています。
509
+
510
+ ## 🙏 謝辞
511
+
512
+ - **Together AI**: 高品質なLLM APIの提供
513
+ - **Streamlit**: 直感的なWebアプリフレームワーク
514
+ - **コミュニティ**: バグ報告や機能提案をいただいた皆様
515
+
516
+ ## 📞 サポート・連絡先
517
+
518
+ - **GitHub Issues**: バグ報告・機能提案
519
+ - **Discussions**: 一般的な質問・議論
520
+ - **Email**: 重要な問題やセキュリティ関連
521
+
522
+ ## 🔄 更新履歴
523
+
524
+ ### v2.1.0 (最新)
525
+ - 🐕 **ポチ機能**: 画面右下の犬アシスタントによる本音一括表示(吹き出し位置を左寄せに調整)
526
+ - 🔓 **セーフティ統合**: 左サイドバーへの統合と色分け表示(URAプロンプト対応)
527
+ - 🎨 **シーン検出強化**: Groq API優先、フォールバック機能付きの高精度シーン変更
528
+ - 📊 **好感度バー改善**: 左寒色→右暖色のグラデーション、動的塗りつぶし表示
529
+ - 🎨 **背景システム改善**: スキップ機能無効化による確実な背景表示
530
+ - 📱 **レスポンシブ対応**: 様々な画面サイズに最適化
531
+ - ✉️ **非同期手紙生成**: バックグラウンド手紙生成
532
+ - 🛡️ **セッション分離強化**: マルチユーザー対応改善
533
+
534
+ ### v2.0.0
535
+ - 🎭 **マスク機能**: 隠された真実の表示機能(現在はポチ機能に統合)
536
+ - 🔓 **セーフティ解除モード**: より大胆な表現モード
537
+ - ✉️ **非同期手紙生成**: バックグラウンド手紙生成
538
+ - 🛡️ **セッション分離強化**: マルチユーザー対応改善
539
+ - 🎨 **UI再設計**: モダンで直感的なインターフェース
540
+
541
+ ### v1.0.0
542
+ - 基本的なチャット機能
543
+ - 好感度システム
544
+ - 動的背景変更
545
+ - メモリ管理機能
546
+
547
+ ---
548
+
549
+ <div align="center">
550
+
551
+ **🐕 Made with ❤️ using Streamlit & Together AI 🐕**
552
+
553
+ *麻理とポチとの特別な時間をお楽しみください*
554
+
555
+ [![Streamlit](https://img.shields.io/badge/Streamlit-FF4B4B?style=for-the-badge&logo=streamlit&logoColor=white)](https://streamlit.io/)
556
+ [![Together AI](https://img.shields.io/badge/Together%20AI-000000?style=for-the-badge&logo=ai&logoColor=white)](https://together.ai/)
557
+ [![Python](https://img.shields.io/badge/Python-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://python.org/)
558
+
559
+ </div>
__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """
2
+ 非同期手紙生成アプリ(麻理AI)
3
+ Asynchronous Letter Generation App (Mari AI)
4
+ """
5
+
6
+ __version__ = "1.0.0"
7
+ __author__ = "Mari AI Team"
app.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ メインアプリケーションモジュール
3
+ Main application module
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+ from letter_config import Config
10
+ from letter_logger import get_app_logger
11
+
12
+ # プロジェクトルートをPythonパスに追加
13
+ project_root = Path(__file__).parent.parent
14
+ sys.path.insert(0, str(project_root))
15
+
16
+ logger = get_app_logger()
17
+
18
+ class LetterApp:
19
+ """非同期手紙生成アプリケーションのメインクラス"""
20
+
21
+ def __init__(self):
22
+ """アプリケーションを初期化"""
23
+ self.config = Config()
24
+ self.logger = logger
25
+ self._initialized = False
26
+
27
+ async def initialize(self) -> bool:
28
+ """
29
+ アプリケーションを初期化する
30
+
31
+ Returns:
32
+ 初期化が成功したかどうか
33
+ """
34
+ try:
35
+ self.logger.info("アプリケーションを初期化中...")
36
+
37
+ # 設定の妥当性をチェック
38
+ if not self.config.validate_config():
39
+ self.logger.error("設定の検証に失敗しました")
40
+ return False
41
+
42
+ # ストレージディレクトリを作成
43
+ await self._setup_storage_directories()
44
+
45
+ # ログディレクトリを作成
46
+ await self._setup_log_directories()
47
+
48
+ self._initialized = True
49
+ self.logger.info("アプリケーションの初期化が完了しました")
50
+ return True
51
+
52
+ except Exception as e:
53
+ self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}")
54
+ return False
55
+
56
+ async def _setup_storage_directories(self):
57
+ """ストレージディレクトリを作成"""
58
+ storage_dir = Path(self.config.STORAGE_PATH).parent
59
+ backup_dir = Path(self.config.BACKUP_PATH)
60
+
61
+ storage_dir.mkdir(parents=True, exist_ok=True)
62
+ backup_dir.mkdir(parents=True, exist_ok=True)
63
+
64
+ self.logger.info(f"ストレージディレクトリを作成: {storage_dir}")
65
+ self.logger.info(f"バックアップディレクトリを作成: {backup_dir}")
66
+
67
+ async def _setup_log_directories(self):
68
+ """ログディレクトリを作成"""
69
+ if not self.config.DEBUG_MODE:
70
+ log_dir = Path("/mnt/data/logs")
71
+ log_dir.mkdir(parents=True, exist_ok=True)
72
+ self.logger.info(f"ログディレクトリを作成: {log_dir}")
73
+
74
+ def is_initialized(self) -> bool:
75
+ """アプリケーションが初期化されているかチェック"""
76
+ return self._initialized
77
+
78
+ def get_config(self) -> Config:
79
+ """設定オブジェクトを取得"""
80
+ return self.config
81
+
82
+ # グローバルアプリケーションインスタンス
83
+ app_instance = None
84
+
85
+ async def get_app() -> LetterApp:
86
+ """アプリケーションインスタンスを取得(シングルトン)"""
87
+ global app_instance
88
+
89
+ if app_instance is None:
90
+ app_instance = LetterApp()
91
+ await app_instance.initialize()
92
+
93
+ return app_instance
94
+
95
+ def run_app():
96
+ """アプリケーションを実行"""
97
+ async def main():
98
+ app = await get_app()
99
+ if app.is_initialized():
100
+ logger.info("アプリケーションが正常に起動しました")
101
+ else:
102
+ logger.error("アプリケーションの起動に失敗しました")
103
+ sys.exit(1)
104
+
105
+ asyncio.run(main())
106
+
107
+ if __name__ == "__main__":
108
+ run_app()
async_config_setup.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 非同期手紙生成システムの設定初期化
3
+ Async Letter Generation System Configuration Setup
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from letter_config import Config
10
+ from letter_logger import get_app_logger
11
+
12
+ logger = get_app_logger()
13
+
14
+ def initialize_config() -> bool:
15
+ """
16
+ 設定を初期化し、必要なディレクトリを作成する
17
+
18
+ Returns:
19
+ 初期化が成功したかどうか
20
+ """
21
+ try:
22
+ # 設定の妥当性をチェック
23
+ if not Config.validate_config():
24
+ logger.error("設定の検証に失敗しました")
25
+ return False
26
+
27
+ # 必要なディレクトリを作成
28
+ storage_path = Path(Config.STORAGE_PATH)
29
+ storage_path.parent.mkdir(parents=True, exist_ok=True)
30
+
31
+ backup_path = Path(Config.BACKUP_PATH)
32
+ backup_path.mkdir(parents=True, exist_ok=True)
33
+
34
+ # ログディレクトリを作成(本番環境用)
35
+ if not Config.DEBUG_MODE:
36
+ log_dir = Path("/tmp")
37
+ log_dir.mkdir(parents=True, exist_ok=True)
38
+
39
+ logger.info("設定初期化が完了しました")
40
+ logger.info(f"ストレージパス: {Config.STORAGE_PATH}")
41
+ logger.info(f"バックアップパス: {Config.BACKUP_PATH}")
42
+ logger.info(f"デバッグモード: {Config.DEBUG_MODE}")
43
+ logger.info(f"バッチ処理時刻: {Config.BATCH_SCHEDULE_HOURS}")
44
+
45
+ return True
46
+
47
+ except Exception as e:
48
+ logger.error(f"設定初期化エラー: {e}")
49
+ return False
50
+
51
+ def check_api_keys() -> bool:
52
+ """
53
+ API キーの存在をチェックする
54
+
55
+ Returns:
56
+ API キーが設定されているかどうか
57
+ """
58
+ try:
59
+ missing_keys = []
60
+
61
+ if not Config.GROQ_API_KEY:
62
+ missing_keys.append("GROQ_API_KEY")
63
+
64
+ if not Config.GEMINI_API_KEY:
65
+ missing_keys.append("GEMINI_API_KEY")
66
+
67
+ if missing_keys:
68
+ logger.error(f"必要なAPI キーが設定されていません: {', '.join(missing_keys)}")
69
+ return False
70
+
71
+ logger.info("API キーの確認が完了しました")
72
+ return True
73
+
74
+ except Exception as e:
75
+ logger.error(f"API キー確認エラー: {e}")
76
+ return False
77
+
78
+ def setup_environment() -> bool:
79
+ """
80
+ 環境をセットアップする
81
+
82
+ Returns:
83
+ セットアップが成功したかどうか
84
+ """
85
+ try:
86
+ # 設定を初期化
87
+ if not initialize_config():
88
+ return False
89
+
90
+ # API キーをチェック
91
+ if not check_api_keys():
92
+ return False
93
+
94
+ # Hugging Face Spaces 固有の設定
95
+ if os.getenv("SPACE_ID"):
96
+ logger.info(f"Hugging Face Spaces環境で実行中: {os.getenv('SPACE_ID')}")
97
+
98
+ # Spaces用の追加設定があればここに記述
99
+
100
+ logger.info("環境セットアップが完了しました")
101
+ return True
102
+
103
+ except Exception as e:
104
+ logger.error(f"環境セットアップエラー: {e}")
105
+ return False
106
+
107
+ def get_system_info() -> dict:
108
+ """
109
+ システム情報を取得する
110
+
111
+ Returns:
112
+ システム情報の辞書
113
+ """
114
+ return {
115
+ "python_version": sys.version,
116
+ "storage_path": Config.STORAGE_PATH,
117
+ "backup_path": Config.BACKUP_PATH,
118
+ "debug_mode": Config.DEBUG_MODE,
119
+ "batch_hours": Config.BATCH_SCHEDULE_HOURS,
120
+ "max_daily_requests": Config.MAX_DAILY_REQUESTS,
121
+ "generation_timeout": Config.GENERATION_TIMEOUT,
122
+ "max_concurrent_generations": Config.MAX_CONCURRENT_GENERATIONS,
123
+ "groq_model": Config.GROQ_MODEL,
124
+ "Together_model": Config.TOGETHER_API_MODEL,
125
+ "available_themes": Config.AVAILABLE_THEMES,
126
+ "space_id": os.getenv("SPACE_ID", "local"),
127
+ "streamlit_port": Config.STREAMLIT_PORT
128
+ }
129
+
130
+ if __name__ == "__main__":
131
+ # スクリプトとして実行された場合の設定確認
132
+ print("非同期手紙生成システム設定確認")
133
+ print("=" * 50)
134
+
135
+ if setup_environment():
136
+ print("✅ 環境セットアップ成功")
137
+
138
+ system_info = get_system_info()
139
+ for key, value in system_info.items():
140
+ print(f"{key}: {value}")
141
+ else:
142
+ print("❌ 環境セットアップ失敗")
143
+ sys.exit(1)
async_letter_app.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ メインアプリケーションモジュール
3
+ Main application module
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # このファイルが依存している他のモジュールをインポート
11
+ from letter_config import Config
12
+ from letter_logger import get_app_logger
13
+
14
+ # このモジュール用のロガーを取得
15
+ logger = get_app_logger()
16
+
17
+ class LetterApp:
18
+ """非同期手紙生成アプリケーションのメインクラス"""
19
+
20
+ def __init__(self):
21
+ """アプリケーションを初期化"""
22
+ self.config = Config()
23
+ self.logger = logger
24
+ self._initialized = False
25
+
26
+ async def initialize(self) -> bool:
27
+ """
28
+ アプリケーションを初期化する
29
+
30
+ Returns:
31
+ 初期化が成功したかどうか
32
+ """
33
+ try:
34
+ self.logger.info("アプリケーションを初期化中...")
35
+
36
+ # 設定の妥当性をチェック
37
+ if not self.config.validate_config():
38
+ self.logger.error("設定の検証に失敗しました")
39
+ return False
40
+
41
+ # ストレージディレクトリを作成
42
+ await self._setup_storage_directories()
43
+
44
+ # ログディレクトリを作成
45
+ await self._setup_log_directories()
46
+
47
+ self._initialized = True
48
+ self.logger.info("アプリケーションの初期化が完了しました")
49
+ return True
50
+
51
+ except Exception as e:
52
+ self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}")
53
+ return False
54
+
55
+ async def _setup_storage_directories(self):
56
+ """ストレージディレクトリを作成"""
57
+ storage_dir = Path(self.config.STORAGE_PATH).parent
58
+ backup_dir = Path(self.config.BACKUP_PATH)
59
+
60
+ storage_dir.mkdir(parents=True, exist_ok=True)
61
+ backup_dir.mkdir(parents=True, exist_ok=True)
62
+
63
+ self.logger.info(f"ストレージディレクトリを作成: {storage_dir}")
64
+ self.logger.info(f"バックアップディレクトリを作成: {backup_dir}")
65
+
66
+ async def _setup_log_directories(self):
67
+ """ログディレクトリを作成"""
68
+ if not self.config.DEBUG_MODE:
69
+ # ログファイルは個別のロガーで設定されるため、
70
+ # ここでは共通の親ディレクトリを作成する例
71
+ log_dir = Path("/tmp/logs") # 例: /tmp/batch.log などの親
72
+ log_dir.mkdir(parents=True, exist_ok=True)
73
+ self.logger.info(f"ログディレクトリを作成: {log_dir}")
74
+
75
+ def is_initialized(self) -> bool:
76
+ """アプリケーションが初期化されているかチェック"""
77
+ return self._initialized
78
+
79
+ def get_config(self) -> Config:
80
+ """設定オブジェクトを取得"""
81
+ return self.config
82
+
83
+ # グローバルアプリケーションインスタンス(シングルトンパターン)
84
+ app_instance = None
85
+
86
+ async def get_app() -> LetterApp:
87
+ """アプリケーションインスタンスを取得(シングルトン)"""
88
+ global app_instance
89
+
90
+ if app_instance is None:
91
+ app_instance = LetterApp()
92
+ await app_instance.initialize()
93
+
94
+ return app_instance
95
+
96
+ def run_app():
97
+ """アプリケーションを実行(テスト用)"""
98
+ async def main():
99
+ app = await get_app()
100
+ if app.is_initialized():
101
+ logger.info("アプリケーションが正常に起動しました")
102
+ else:
103
+ logger.error("アプリケーションの起動に失敗しました")
104
+ sys.exit(1)
105
+
106
+ asyncio.run(main())
107
+
108
+ # このファイルが直接実行された場合にテスト用の起動処理を行う
109
+ if __name__ == "__main__":
110
+ run_app()
async_rate_limiter.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 非同期レート制限管理クラス
3
+ 1日1回制限とAPI呼び出し制限を管理し、
4
+ デバッグモード時の制限緩和機能を提供します。
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict, Any, Optional, Tuple
11
+ import logging
12
+
13
+ # ログ設定
14
+ logging.basicConfig(level=logging.INFO)
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class RateLimitError(Exception):
19
+ """レート制限エラー"""
20
+ pass
21
+
22
+
23
+ class AsyncRateLimitManager:
24
+ """非同期レート制限管理クラス"""
25
+
26
+ def __init__(self, storage_manager, max_requests: int = 1):
27
+ self.storage = storage_manager
28
+
29
+ # 設定値(環境変数から取得、デフォルト値あり)
30
+ self.max_daily_requests = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
31
+ self.max_api_calls_per_day = int(os.getenv("MAX_API_CALLS_PER_DAY", "10"))
32
+ self.debug_mode = os.getenv("DEBUG_MODE", "false").lower() == "true"
33
+
34
+ # デバッグモード時の制限緩和
35
+ if self.debug_mode:
36
+ self.max_daily_requests = int(os.getenv("DEBUG_MAX_DAILY_REQUESTS", "10"))
37
+ self.max_api_calls_per_day = int(os.getenv("DEBUG_MAX_API_CALLS", "100"))
38
+ logger.info("デバッグモードが有効です。制限が緩和されています")
39
+
40
+ logger.info(f"レート制限設定 - 1日のリクエスト上限: {self.max_daily_requests}, API呼び出し上限: {self.max_api_calls_per_day}")
41
+
42
+ async def check_daily_request_limit(self, user_id: str) -> Tuple[bool, Dict[str, Any]]:
43
+ """1日のリクエスト制限をチェック"""
44
+ try:
45
+ user_data = await self.storage.get_user_data(user_id)
46
+ today = datetime.now().strftime("%Y-%m-%d")
47
+
48
+ # 今日のリクエスト数を取得
49
+ daily_requests = user_data["rate_limits"]["daily_requests"]
50
+ today_requests = daily_requests.get(today, 0)
51
+
52
+ # 制限チェック
53
+ is_allowed = today_requests < self.max_daily_requests
54
+
55
+ limit_info = {
56
+ "today_requests": today_requests,
57
+ "max_requests": self.max_daily_requests,
58
+ "remaining": max(0, self.max_daily_requests - today_requests),
59
+ "reset_time": self._get_next_reset_time(),
60
+ "debug_mode": self.debug_mode
61
+ }
62
+
63
+ if not is_allowed:
64
+ logger.warning(f"ユーザー {user_id} の1日のリクエスト制限に達しました ({today_requests}/{self.max_daily_requests})")
65
+
66
+ return is_allowed, limit_info
67
+
68
+ except Exception as e:
69
+ logger.error(f"1日のリクエスト制限チェックエラー: {e}")
70
+ # エラー時は制限を適用
71
+ return False, {"error": str(e)}
72
+
73
+ async def check_api_call_limit(self, user_id: str) -> Tuple[bool, Dict[str, Any]]:
74
+ """API呼び出し制限をチェック"""
75
+ try:
76
+ user_data = await self.storage.get_user_data(user_id)
77
+ today = datetime.now().strftime("%Y-%m-%d")
78
+
79
+ # 今日のAPI呼び出し数を取得
80
+ api_calls = user_data["rate_limits"]["api_calls"]
81
+ today_calls = api_calls.get(today, 0)
82
+
83
+ # 制限チェック
84
+ is_allowed = today_calls < self.max_api_calls_per_day
85
+
86
+ limit_info = {
87
+ "today_calls": today_calls,
88
+ "max_calls": self.max_api_calls_per_day,
89
+ "remaining": max(0, self.max_api_calls_per_day - today_calls),
90
+ "reset_time": self._get_next_reset_time(),
91
+ "debug_mode": self.debug_mode
92
+ }
93
+
94
+ if not is_allowed:
95
+ logger.warning(f"ユーザー {user_id} のAPI呼び出し制限に達しました ({today_calls}/{self.max_api_calls_per_day})")
96
+
97
+ return is_allowed, limit_info
98
+
99
+ except Exception as e:
100
+ logger.error(f"API呼び出し制限チェックエラー: {e}")
101
+ # エラー時は制限を適用
102
+ return False, {"error": str(e)}
103
+
104
+ async def record_request(self, user_id: str) -> None:
105
+ """リクエストを記録"""
106
+ try:
107
+ user_data = await self.storage.get_user_data(user_id)
108
+ today = datetime.now().strftime("%Y-%m-%d")
109
+
110
+ # 今日のリクエスト数を増加
111
+ if "daily_requests" not in user_data["rate_limits"]:
112
+ user_data["rate_limits"]["daily_requests"] = {}
113
+
114
+ user_data["rate_limits"]["daily_requests"][today] = \
115
+ user_data["rate_limits"]["daily_requests"].get(today, 0) + 1
116
+
117
+ # プロファイルの最終リクエスト日を更新
118
+ user_data["profile"]["last_request"] = today
119
+
120
+ await self.storage.update_user_data(user_id, user_data)
121
+
122
+ logger.info(f"ユーザー {user_id} のリクエストを記録しました")
123
+
124
+ except Exception as e:
125
+ logger.error(f"リクエスト記録エラー: {e}")
126
+ raise RateLimitError(f"リクエストの記録に失敗しました: {e}")
127
+
128
+ async def record_api_call(self, user_id: str, api_type: str = "general") -> None:
129
+ """API呼び出しを記録"""
130
+ try:
131
+ user_data = await self.storage.get_user_data(user_id)
132
+ today = datetime.now().strftime("%Y-%m-%d")
133
+
134
+ # 今日のAPI呼び出し数を増加
135
+ if "api_calls" not in user_data["rate_limits"]:
136
+ user_data["rate_limits"]["api_calls"] = {}
137
+
138
+ user_data["rate_limits"]["api_calls"][today] = \
139
+ user_data["rate_limits"]["api_calls"].get(today, 0) + 1
140
+
141
+ await self.storage.update_user_data(user_id, user_data)
142
+
143
+ logger.info(f"ユーザー {user_id} のAPI呼び出し ({api_type}) を記録しました")
144
+
145
+ except Exception as e:
146
+ logger.error(f"API呼び出し記録エラー: {e}")
147
+ raise RateLimitError(f"API呼び出しの記録に失敗しました: {e}")
148
+
149
+ async def get_user_limits_status(self, user_id: str) -> Dict[str, Any]:
150
+ """ユーザーの制限状況を取得"""
151
+ try:
152
+ # リクエスト制限の確認
153
+ request_allowed, request_info = await self.check_daily_request_limit(user_id)
154
+
155
+ # API呼び出し制限の確認
156
+ api_allowed, api_info = await self.check_api_call_limit(user_id)
157
+
158
+ # 次回リクエスト可能時刻の計算
159
+ next_request_time = None
160
+ if not request_allowed:
161
+ next_request_time = self._get_next_reset_time()
162
+
163
+ return {
164
+ "request_limit": {
165
+ "allowed": request_allowed,
166
+ "info": request_info
167
+ },
168
+ "api_limit": {
169
+ "allowed": api_allowed,
170
+ "info": api_info
171
+ },
172
+ "next_request_time": next_request_time,
173
+ "debug_mode": self.debug_mode
174
+ }
175
+
176
+ except Exception as e:
177
+ logger.error(f"制限状況取得エラー: {e}")
178
+ return {"error": str(e)}
179
+
180
+ async def reset_daily_counters(self) -> int:
181
+ """1日のカウンターをリセット(古いデータを削除)"""
182
+ try:
183
+ # 7日以上前のデータを削除
184
+ cutoff_date = datetime.now() - timedelta(days=7)
185
+ cutoff_str = cutoff_date.strftime("%Y-%m-%d")
186
+
187
+ all_users = await self.storage.get_all_users()
188
+ reset_count = 0
189
+
190
+ for user_id in all_users:
191
+ user_data = await self.storage.get_user_data(user_id)
192
+
193
+ # 古い1日のリクエストデータを削除
194
+ daily_requests = user_data["rate_limits"]["daily_requests"]
195
+ dates_to_delete = [date for date in daily_requests.keys() if date < cutoff_str]
196
+
197
+ for date in dates_to_delete:
198
+ del daily_requests[date]
199
+ reset_count += 1
200
+
201
+ # 古いAPI呼び出しデータを削除
202
+ api_calls = user_data["rate_limits"]["api_calls"]
203
+ dates_to_delete = [date for date in api_calls.keys() if date < cutoff_str]
204
+
205
+ for date in dates_to_delete:
206
+ del api_calls[date]
207
+ reset_count += 1
208
+
209
+ if reset_count > 0:
210
+ await self.storage.update_user_data(user_id, user_data)
211
+
212
+ if reset_count > 0:
213
+ logger.info(f"{reset_count}件の古い制限データをリセットしました")
214
+
215
+ return reset_count
216
+
217
+ except Exception as e:
218
+ logger.error(f"カウンターリセットエラー: {e}")
219
+ return 0
220
+
221
+ def _get_next_reset_time(self) -> str:
222
+ """次のリセット時刻を取得(翌日の0時)"""
223
+ tomorrow = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
224
+ return tomorrow.isoformat()
225
+
226
+ async def is_request_allowed(self, user_id: str) -> Tuple[bool, str]:
227
+ """リクエストが許可されているかチェック(統合チェック)"""
228
+ try:
229
+ # 1日のリクエスト制限チェック
230
+ request_allowed, request_info = await self.check_daily_request_limit(user_id)
231
+
232
+ if not request_allowed:
233
+ remaining_time = self._calculate_remaining_time()
234
+ return False, f"1日のリクエスト制限に達しています。次回リクエスト可能時刻: {remaining_time}"
235
+
236
+ # API呼び出し制限チェック
237
+ api_allowed, api_info = await self.check_api_call_limit(user_id)
238
+
239
+ if not api_allowed:
240
+ remaining_time = self._calculate_remaining_time()
241
+ return False, f"API呼び出し制限に達しています。次回リクエスト可能時刻: {remaining_time}"
242
+
243
+ return True, "リクエスト可能です"
244
+
245
+ except Exception as e:
246
+ logger.error(f"リクエスト許可チェックエラー: {e}")
247
+ return False, f"制限チェック中にエラーが発生しました: {e}"
248
+
249
+ def _calculate_remaining_time(self) -> str:
250
+ """次回リクエスト可能までの残り時間を計算"""
251
+ now = datetime.now()
252
+ tomorrow = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
253
+ remaining = tomorrow - now
254
+
255
+ hours = remaining.seconds // 3600
256
+ minutes = (remaining.seconds % 3600) // 60
257
+
258
+ return f"{hours}時間{minutes}分後"
259
+
260
+ async def get_rate_limit_stats(self) -> Dict[str, Any]:
261
+ """レート制限の統計情報を取得"""
262
+ try:
263
+ all_users = await self.storage.get_all_users()
264
+ today = datetime.now().strftime("%Y-%m-%d")
265
+
266
+ total_requests_today = 0
267
+ total_api_calls_today = 0
268
+ active_users_today = 0
269
+
270
+ for user_id in all_users:
271
+ user_data = await self.storage.get_user_data(user_id)
272
+
273
+ # 今日のリクエスト数
274
+ daily_requests = user_data["rate_limits"]["daily_requests"]
275
+ user_requests_today = daily_requests.get(today, 0)
276
+ total_requests_today += user_requests_today
277
+
278
+ # 今日のAPI呼び出し数
279
+ api_calls = user_data["rate_limits"]["api_calls"]
280
+ user_api_calls_today = api_calls.get(today, 0)
281
+ total_api_calls_today += user_api_calls_today
282
+
283
+ # アクティブユーザー数
284
+ if user_requests_today > 0 or user_api_calls_today > 0:
285
+ active_users_today += 1
286
+
287
+ return {
288
+ "total_users": len(all_users),
289
+ "active_users_today": active_users_today,
290
+ "total_requests_today": total_requests_today,
291
+ "total_api_calls_today": total_api_calls_today,
292
+ "max_daily_requests": self.max_daily_requests,
293
+ "max_api_calls_per_day": self.max_api_calls_per_day,
294
+ "debug_mode": self.debug_mode,
295
+ "date": today
296
+ }
297
+
298
+ except Exception as e:
299
+ logger.error(f"統計情報取得エラー: {e}")
300
+ return {"error": str(e)}
301
+
302
+ def is_debug_mode(self) -> bool:
303
+ """デバッグモードかどうかを確認"""
304
+ return self.debug_mode
305
+
306
+ async def set_debug_mode(self, enabled: bool) -> None:
307
+ """デバッグモードの設定(動的変更)"""
308
+ self.debug_mode = enabled
309
+
310
+ if enabled:
311
+ self.max_daily_requests = int(os.getenv("DEBUG_MAX_DAILY_REQUESTS", "10"))
312
+ self.max_api_calls_per_day = int(os.getenv("DEBUG_MAX_API_CALLS", "100"))
313
+ logger.info("デバッグモードを有効にしました")
314
+ else:
315
+ self.max_daily_requests = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
316
+ self.max_api_calls_per_day = int(os.getenv("MAX_API_CALLS_PER_DAY", "10"))
317
+ logger.info("デバッグモードを無効にしました")
318
+
319
+ async def force_reset_user_limits(self, user_id: str) -> None:
320
+ """特定ユーザーの制限を強制リセット(デバッグ用)"""
321
+ if not self.debug_mode:
322
+ raise RateLimitError("デバッグモードでのみ利用可能です")
323
+
324
+ try:
325
+ user_data = await self.storage.get_user_data(user_id)
326
+ today = datetime.now().strftime("%Y-%m-%d")
327
+
328
+ # 今日の制限をリセット
329
+ user_data["rate_limits"]["daily_requests"][today] = 0
330
+ user_data["rate_limits"]["api_calls"][today] = 0
331
+
332
+ await self.storage.update_user_data(user_id, user_data)
333
+
334
+ logger.info(f"ユーザー {user_id} の制限を強制リセットしました")
335
+
336
+ except Exception as e:
337
+ logger.error(f"強制リセットエラー: {e}")
338
+ raise RateLimitError(f"制限のリセットに失敗しました: {e}")
339
+
340
+
341
+ # テスト用の関数
342
+ async def test_rate_limit_manager():
343
+ """RateLimitManagerのテスト"""
344
+ import tempfile
345
+ import uuid
346
+ from async_storage_manager import AsyncStorageManager
347
+
348
+ # 一時ディレクトリでテスト
349
+ with tempfile.TemporaryDirectory() as temp_dir:
350
+ test_file = os.path.join(temp_dir, "test_letters.json")
351
+ storage = AsyncStorageManager(test_file)
352
+ rate_limiter = AsyncRateLimitManager(storage)
353
+
354
+ print("=== RateLimitManagerテスト開始 ===")
355
+
356
+ user_id = str(uuid.uuid4())
357
+
358
+ # 初回リクエストチェック
359
+ allowed, message = await rate_limiter.is_request_allowed(user_id)
360
+ print(f"✓ 初回リクエストチェック: {allowed} - {message}")
361
+
362
+ # リクエスト記録
363
+ await rate_limiter.record_request(user_id)
364
+ print("✓ リクエスト記録成功")
365
+
366
+ # API呼び出し記録
367
+ await rate_limiter.record_api_call(user_id, "groq")
368
+ print("✓ API呼び出し記録成功")
369
+
370
+ # 制限状況確認
371
+ status = await rate_limiter.get_user_limits_status(user_id)
372
+ print(f"✓ 制限状況確認: {status}")
373
+
374
+ # 統計情報取得
375
+ stats = await rate_limiter.get_rate_limit_stats()
376
+ print(f"✓ 統計情報取得: {stats}")
377
+
378
+ # デバッグモードテスト
379
+ await rate_limiter.set_debug_mode(True)
380
+ print("✓ デバッグモード有効化成功")
381
+
382
+ # 強制リセットテスト(デバッグモード時のみ)
383
+ await rate_limiter.force_reset_user_limits(user_id)
384
+ print("✓ 強制リセット成功")
385
+
386
+ print("=== 全てのテストが完了しました! ===")
387
+
388
+
389
+ if __name__ == "__main__":
390
+ asyncio.run(test_rate_limit_manager())
async_storage_manager.py ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 非同期ストレージ管理クラス
3
+ JSONファイルベースの永続ストレージを提供し、
4
+ 非同期ファイル操作とロック機能を実装します。
5
+ """
6
+
7
+ import asyncio
8
+ import json
9
+ import os
10
+ import shutil
11
+ from datetime import datetime, timedelta
12
+ from typing import Dict, Any, Optional, List
13
+ from pathlib import Path
14
+ import logging
15
+
16
+ # ログ設定
17
+ logging.basicConfig(level=logging.INFO)
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class StorageError(Exception):
22
+ """ストレージ関連のエラー"""
23
+ pass
24
+
25
+
26
+ class AsyncStorageManager:
27
+ """非同期ストレージ管理クラス"""
28
+
29
+ def __init__(self, file_path: str = "tmp/letters.json"):
30
+ self.file_path = Path(file_path)
31
+ self.backup_dir = self.file_path.parent / "backup"
32
+ self.lock = asyncio.Lock()
33
+
34
+ # ディレクトリの作成
35
+ self.file_path.parent.mkdir(parents=True, exist_ok=True)
36
+ self.backup_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ # 初期データ構造
39
+ self.default_data = {
40
+ "users": {},
41
+ "system": {
42
+ "last_backup": None,
43
+ "batch_runs": {},
44
+ "created_at": datetime.now().isoformat()
45
+ }
46
+ }
47
+
48
+ async def load_data(self) -> Dict[str, Any]:
49
+ """データファイルを読み込み"""
50
+ async with self.lock:
51
+ try:
52
+ if not self.file_path.exists():
53
+ logger.info("データファイルが存在しないため、初期データを作成します")
54
+ await self._save_data_unsafe(self.default_data)
55
+ return self.default_data.copy()
56
+
57
+ # ファイルサイズチェック
58
+ if self.file_path.stat().st_size == 0:
59
+ logger.warning("データファイルが空のため、初期データを作成します")
60
+ await self._save_data_unsafe(self.default_data)
61
+ return self.default_data.copy()
62
+
63
+ # JSONファイルの読み込み
64
+ with open(self.file_path, 'r', encoding='utf-8') as f:
65
+ data = json.load(f)
66
+
67
+ # データ構造の検証と修復
68
+ data = self._validate_and_repair_data(data)
69
+
70
+ logger.info(f"データファイルを正常に読み込みました: {self.file_path}")
71
+ return data
72
+
73
+ except json.JSONDecodeError as e:
74
+ logger.error(f"JSONファイルの形式が不正です: {e}")
75
+ # バックアップからの復旧を試行
76
+ return await self._restore_from_backup()
77
+
78
+ except Exception as e:
79
+ logger.error(f"データ読み込みエラー: {e}")
80
+ raise StorageError(f"データの読み込みに失敗しました: {e}")
81
+
82
+ async def save_data(self, data: Dict[str, Any]) -> None:
83
+ """データファイルに保存"""
84
+ async with self.lock:
85
+ await self._save_data_unsafe(data)
86
+
87
+ async def _save_data_unsafe(self, data: Dict[str, Any]) -> None:
88
+ """ロックなしでデータを保存(内部使用)"""
89
+ try:
90
+ # データの検証
91
+ validated_data = self._validate_and_repair_data(data)
92
+
93
+ # 一時ファイルに書き込み
94
+ temp_path = self.file_path.with_suffix('.tmp')
95
+
96
+ with open(temp_path, 'w', encoding='utf-8') as f:
97
+ json.dump(validated_data, f, ensure_ascii=False, indent=2)
98
+
99
+ # アトミックな移動
100
+ shutil.move(str(temp_path), str(self.file_path))
101
+
102
+ logger.info(f"データを正常に保存しました: {self.file_path}")
103
+
104
+ except Exception as e:
105
+ logger.error(f"データ保存エラー: {e}")
106
+ # 一時ファイルのクリーンアップ
107
+ temp_path = self.file_path.with_suffix('.tmp')
108
+ if temp_path.exists():
109
+ temp_path.unlink()
110
+ raise StorageError(f"データの保存に失敗しました: {e}")
111
+
112
+ async def get_user_data(self, user_id: str) -> Dict[str, Any]:
113
+ """特定ユーザーのデータを取得"""
114
+ data = await self.load_data()
115
+
116
+ if user_id not in data["users"]:
117
+ # 新規ユーザーの初期データを作成
118
+ user_data = {
119
+ "profile": {
120
+ "created_at": datetime.now().isoformat(),
121
+ "last_request": None,
122
+ "total_letters": 0
123
+ },
124
+ "letters": {},
125
+ "requests": {},
126
+ "rate_limits": {
127
+ "daily_requests": {},
128
+ "api_calls": {}
129
+ }
130
+ }
131
+ data["users"][user_id] = user_data
132
+ await self.save_data(data)
133
+ logger.info(f"新規ユーザーデータを作成しました: {user_id}")
134
+
135
+ return data["users"][user_id]
136
+
137
+ async def update_user_data(self, user_id: str, user_data: Dict[str, Any]) -> None:
138
+ """特定ユーザーのデータを更新"""
139
+ data = await self.load_data()
140
+ data["users"][user_id] = user_data
141
+ await self.save_data(data)
142
+ logger.info(f"ユーザーデータを更新しました: {user_id}")
143
+
144
+ async def get_all_users(self) -> List[str]:
145
+ """全ユーザーIDのリストを取得"""
146
+ data = await self.load_data()
147
+ return list(data["users"].keys())
148
+
149
+ async def backup_data(self) -> str:
150
+ """データのバックアップを作成"""
151
+ try:
152
+ if not self.file_path.exists():
153
+ logger.warning("バックアップ対象のファイルが存在しません")
154
+ return ""
155
+
156
+ # バックアップファイル名(タイムスタンプ付き)
157
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
158
+ backup_filename = f"letters_backup_{timestamp}.json"
159
+ backup_path = self.backup_dir / backup_filename
160
+
161
+ # ファイルをコピー
162
+ shutil.copy2(str(self.file_path), str(backup_path))
163
+
164
+ # システム情報を更新
165
+ data = await self.load_data()
166
+ data["system"]["last_backup"] = datetime.now().isoformat()
167
+ await self.save_data(data)
168
+
169
+ logger.info(f"バックアップを作成しました: {backup_path}")
170
+
171
+ # 古いバックアップファイルを削除(7日以上前)
172
+ await self._cleanup_old_backups()
173
+
174
+ return str(backup_path)
175
+
176
+ except Exception as e:
177
+ logger.error(f"バックアップ作成エラー: {e}")
178
+ raise StorageError(f"バックアップの作成に失敗しました: {e}")
179
+
180
+ async def _restore_from_backup(self) -> Dict[str, Any]:
181
+ """最新のバックアップから復旧"""
182
+ try:
183
+ # バックアップファイルを検索
184
+ backup_files = list(self.backup_dir.glob("letters_backup_*.json"))
185
+
186
+ if not backup_files:
187
+ logger.warning("バックアップファイルが見つかりません。初期データを使用します")
188
+ await self._save_data_unsafe(self.default_data)
189
+ return self.default_data.copy()
190
+
191
+ # 最新のバックアップファイルを選択
192
+ latest_backup = max(backup_files, key=lambda p: p.stat().st_mtime)
193
+
194
+ logger.info(f"バックアップから復旧します: {latest_backup}")
195
+
196
+ with open(latest_backup, 'r', encoding='utf-8') as f:
197
+ data = json.load(f)
198
+
199
+ # 復旧したデータを保存
200
+ await self._save_data_unsafe(data)
201
+
202
+ return data
203
+
204
+ except Exception as e:
205
+ logger.error(f"バックアップからの復旧に失敗: {e}")
206
+ logger.info("初期データを使用します")
207
+ await self._save_data_unsafe(self.default_data)
208
+ return self.default_data.copy()
209
+
210
+ async def _cleanup_old_backups(self, days: int = 7) -> None:
211
+ """古いバックアップファイルを削除"""
212
+ try:
213
+ cutoff_date = datetime.now() - timedelta(days=days)
214
+ backup_files = list(self.backup_dir.glob("letters_backup_*.json"))
215
+
216
+ deleted_count = 0
217
+ for backup_file in backup_files:
218
+ file_time = datetime.fromtimestamp(backup_file.stat().st_mtime)
219
+ if file_time < cutoff_date:
220
+ backup_file.unlink()
221
+ deleted_count += 1
222
+
223
+ if deleted_count > 0:
224
+ logger.info(f"{deleted_count}個の古いバックアップファイルを削除しました")
225
+
226
+ except Exception as e:
227
+ logger.error(f"バックアップファイルの削除エラー: {e}")
228
+
229
+ def _validate_and_repair_data(self, data: Dict[str, Any]) -> Dict[str, Any]:
230
+ """データ構造の検証と修復"""
231
+ if not isinstance(data, dict):
232
+ logger.warning("データが辞書形式ではありません。初期データを使用します")
233
+ return self.default_data.copy()
234
+
235
+ # 必要なキーの確認と修復
236
+ if "users" not in data:
237
+ data["users"] = {}
238
+
239
+ if "system" not in data:
240
+ data["system"] = self.default_data["system"].copy()
241
+
242
+ # システム情報の修復
243
+ system_defaults = {
244
+ "last_backup": None,
245
+ "batch_runs": {},
246
+ "created_at": datetime.now().isoformat()
247
+ }
248
+
249
+ for key, default_value in system_defaults.items():
250
+ if key not in data["system"]:
251
+ data["system"][key] = default_value
252
+
253
+ # ユーザーデータの修復
254
+ for user_id, user_data in data["users"].items():
255
+ if not isinstance(user_data, dict):
256
+ continue
257
+
258
+ # 必要なキーの確認
259
+ user_defaults = {
260
+ "profile": {
261
+ "created_at": datetime.now().isoformat(),
262
+ "last_request": None,
263
+ "total_letters": 0
264
+ },
265
+ "letters": {},
266
+ "requests": {},
267
+ "rate_limits": {
268
+ "daily_requests": {},
269
+ "api_calls": {}
270
+ }
271
+ }
272
+
273
+ for key, default_value in user_defaults.items():
274
+ if key not in user_data:
275
+ user_data[key] = default_value
276
+
277
+ return data
278
+
279
+ async def get_system_info(self) -> Dict[str, Any]:
280
+ """システム情報を取得"""
281
+ data = await self.load_data()
282
+ return data["system"]
283
+
284
+ async def update_system_info(self, system_info: Dict[str, Any]) -> None:
285
+ """システム情報を更新"""
286
+ data = await self.load_data()
287
+ data["system"].update(system_info)
288
+ await self.save_data(data)
289
+
290
+ async def cleanup_old_data(self, days: int = 90) -> int:
291
+ """古いデータを削除"""
292
+ try:
293
+ cutoff_date = datetime.now() - timedelta(days=days)
294
+ cutoff_str = cutoff_date.strftime("%Y-%m-%d")
295
+
296
+ data = await self.load_data()
297
+ deleted_count = 0
298
+
299
+ for user_id, user_data in data["users"].items():
300
+ # 古い手紙を削除
301
+ letters_to_delete = []
302
+ for date_str in user_data["letters"]:
303
+ if date_str < cutoff_str:
304
+ letters_to_delete.append(date_str)
305
+
306
+ for date_str in letters_to_delete:
307
+ del user_data["letters"][date_str]
308
+ deleted_count += 1
309
+
310
+ # 古いリクエストを削除
311
+ requests_to_delete = []
312
+ for date_str in user_data["requests"]:
313
+ if date_str < cutoff_str:
314
+ requests_to_delete.append(date_str)
315
+
316
+ for date_str in requests_to_delete:
317
+ del user_data["requests"][date_str]
318
+
319
+ # 古いレート制限データを削除
320
+ for limit_type in ["daily_requests", "api_calls"]:
321
+ if limit_type in user_data["rate_limits"]:
322
+ dates_to_delete = []
323
+ for date_str in user_data["rate_limits"][limit_type]:
324
+ if date_str < cutoff_str:
325
+ dates_to_delete.append(date_str)
326
+
327
+ for date_str in dates_to_delete:
328
+ del user_data["rate_limits"][limit_type][date_str]
329
+
330
+ if deleted_count > 0:
331
+ await self.save_data(data)
332
+ logger.info(f"{deleted_count}件の古いデータを削除しました")
333
+
334
+ return deleted_count
335
+
336
+ except Exception as e:
337
+ logger.error(f"古いデータの削除エラー: {e}")
338
+ return 0
339
+
340
+ async def get_storage_stats(self) -> Dict[str, Any]:
341
+ """ストレージの統計情報を取得"""
342
+ try:
343
+ data = await self.load_data()
344
+
345
+ total_users = len(data["users"])
346
+ total_letters = sum(len(user_data["letters"]) for user_data in data["users"].values())
347
+ total_requests = sum(len(user_data["requests"]) for user_data in data["users"].values())
348
+
349
+ file_size = self.file_path.stat().st_size if self.file_path.exists() else 0
350
+ backup_count = len(list(self.backup_dir.glob("letters_backup_*.json")))
351
+
352
+ return {
353
+ "total_users": total_users,
354
+ "total_letters": total_letters,
355
+ "total_requests": total_requests,
356
+ "file_size_bytes": file_size,
357
+ "backup_count": backup_count,
358
+ "last_backup": data["system"].get("last_backup"),
359
+ "created_at": data["system"].get("created_at")
360
+ }
361
+
362
+ except Exception as e:
363
+ logger.error(f"統計情報の取得エラー: {e}")
364
+ return {}
365
+
366
+
367
+ # テスト用の関数
368
+ async def test_storage_manager():
369
+ """StorageManagerのテスト"""
370
+ import tempfile
371
+ import uuid
372
+
373
+ # 一時ディレクトリでテスト
374
+ with tempfile.TemporaryDirectory() as temp_dir:
375
+ test_file = os.path.join(temp_dir, "test_letters.json")
376
+ storage = AsyncStorageManager(test_file)
377
+
378
+ print("=== StorageManagerテスト開始 ===")
379
+
380
+ # 初期データの読み込みテスト
381
+ data = await storage.load_data()
382
+ print("✓ 初期データの読み込み成功")
383
+
384
+ # ユーザーデータの作成テスト
385
+ user_id = str(uuid.uuid4())
386
+ user_data = await storage.get_user_data(user_id)
387
+ print("✓ ユーザーデータの作成成功")
388
+
389
+ # ユーザーデータの更新テスト
390
+ user_data["profile"]["total_letters"] = 1
391
+ user_data["letters"]["2024-01-20"] = {
392
+ "theme": "テストテーマ",
393
+ "content": "テスト手紙の内容",
394
+ "status": "completed",
395
+ "generated_at": datetime.now().isoformat()
396
+ }
397
+ await storage.update_user_data(user_id, user_data)
398
+ print("✓ ユーザーデータの更新成功")
399
+
400
+ # データの再読み込みテスト
401
+ updated_data = await storage.get_user_data(user_id)
402
+ assert updated_data["profile"]["total_letters"] == 1
403
+ print("✓ データの永続化確認成功")
404
+
405
+ # バックアップテスト
406
+ backup_path = await storage.backup_data()
407
+ print(f"✓ バックアップ作成成功: {backup_path}")
408
+
409
+ # 統計情報テスト
410
+ stats = await storage.get_storage_stats()
411
+ print(f"✓ 統計情報取得成功: {stats}")
412
+
413
+ print("=== 全てのテストが完了しました! ===")
414
+
415
+
416
+ if __name__ == "__main__":
417
+ asyncio.run(test_storage_manager())
background_processor.py ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ バックグラウンド処理統合クラス
3
+ Streamlitアプリと独立したバッチ処理の実行機能と
4
+ 古いデータの自動削除機能を提供します。
5
+ """
6
+
7
+ import asyncio
8
+ import threading
9
+ import time
10
+ import os
11
+ from datetime import datetime, timedelta
12
+ from typing import Dict, Any, Optional, Callable
13
+ import logging
14
+ import traceback
15
+ import signal
16
+ import sys
17
+
18
+ from batch_scheduler import BatchScheduler
19
+ from async_storage_manager import AsyncStorageManager
20
+
21
+ # ログ設定
22
+ logging.basicConfig(level=logging.INFO)
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class BackgroundProcessorError(Exception):
27
+ """バックグラウンドプロセッサー関連のエラー"""
28
+ pass
29
+
30
+
31
+ class BackgroundProcessor:
32
+ """Streamlitアプリと独立したバックグラウンド処理管理クラス"""
33
+
34
+ def __init__(self, storage_manager: Optional[AsyncStorageManager] = None):
35
+ """
36
+ バックグラウンドプロセッサーを初期化
37
+
38
+ Args:
39
+ storage_manager: ストレージマネージャー(指定しない場合は新規作成)
40
+ """
41
+ # ストレージマネージャーの初期化
42
+ if storage_manager is None:
43
+ storage_path = os.getenv("STORAGE_PATH", "/mnt/data/letters.json")
44
+ self.storage_manager = AsyncStorageManager(storage_path)
45
+ else:
46
+ self.storage_manager = storage_manager
47
+
48
+ # バッチスケジューラーの初期化
49
+ self.batch_scheduler = BatchScheduler(self.storage_manager)
50
+
51
+ # 設定値
52
+ self.target_hours = [2, 3, 4] # 2時、3時、4時
53
+ self.check_interval = int(os.getenv("BATCH_CHECK_INTERVAL", "60")) # 1分間隔
54
+ self.cleanup_hour = int(os.getenv("CLEANUP_HOUR", "1")) # 1時にクリーンアップ
55
+ self.cleanup_retention_days = int(os.getenv("CLEANUP_RETENTION_DAYS", "90"))
56
+ self.enable_background_processing = os.getenv("ENABLE_BACKGROUND_PROCESSING", "true").lower() == "true"
57
+
58
+ # 実行状態管理
59
+ self.is_running = False
60
+ self.background_thread = None
61
+ self.stop_event = threading.Event()
62
+ self.last_execution_times = {hour: None for hour in self.target_hours}
63
+ self.last_cleanup_date = None
64
+
65
+ # コールバック関数
66
+ self.on_batch_complete: Optional[Callable] = None
67
+ self.on_cleanup_complete: Optional[Callable] = None
68
+ self.on_error: Optional[Callable] = None
69
+
70
+ logger.info(f"BackgroundProcessor初期化完了 - 対象時刻: {self.target_hours}, チェック間隔: {self.check_interval}秒")
71
+
72
+ def start_background_processing(self) -> bool:
73
+ """
74
+ バックグラウンド処理を開始
75
+
76
+ Returns:
77
+ bool: 開始成功フラグ
78
+ """
79
+ if not self.enable_background_processing:
80
+ logger.info("バックグラウンド処理は無効化されています")
81
+ return False
82
+
83
+ if self.is_running:
84
+ logger.warning("バックグラウンド処理は既に実行中です")
85
+ return False
86
+
87
+ try:
88
+ self.is_running = True
89
+ self.stop_event.clear()
90
+
91
+ # バックグラウンドスレッドを開始
92
+ self.background_thread = threading.Thread(
93
+ target=self._background_loop,
94
+ name="BackgroundProcessor",
95
+ daemon=True
96
+ )
97
+ self.background_thread.start()
98
+
99
+ # シグナルハンドラーを設定
100
+ self._setup_signal_handlers()
101
+
102
+ logger.info("バックグラウンド処理を開始しました")
103
+ return True
104
+
105
+ except Exception as e:
106
+ self.is_running = False
107
+ logger.error(f"バックグラウンド処理の開始に失敗: {str(e)}")
108
+ return False
109
+
110
+ def stop_background_processing(self) -> bool:
111
+ """
112
+ バックグラウンド処理を停止
113
+
114
+ Returns:
115
+ bool: 停止成功フラグ
116
+ """
117
+ if not self.is_running:
118
+ logger.info("バックグラウンド処理は実行されていません")
119
+ return True
120
+
121
+ try:
122
+ logger.info("バックグラウンド処理の停止を開始します...")
123
+
124
+ # 停止フラグを設定
125
+ self.stop_event.set()
126
+ self.is_running = False
127
+
128
+ # スレッドの終了を待機
129
+ if self.background_thread and self.background_thread.is_alive():
130
+ self.background_thread.join(timeout=30) # 30秒でタイムアウト
131
+
132
+ if self.background_thread.is_alive():
133
+ logger.warning("バックグラウンドスレッドの停止がタイムアウトしました")
134
+ return False
135
+
136
+ logger.info("バックグラウンド処理を停止しました")
137
+ return True
138
+
139
+ except Exception as e:
140
+ logger.error(f"バックグラウンド処理の停止に失敗: {str(e)}")
141
+ return False
142
+
143
+ def start_background_processing(self) -> bool:
144
+ if not self.enable_background_processing:
145
+ logger.info("バックグラウンド処理は無効化されています")
146
+ return False
147
+ if self.is_running:
148
+ logger.warning("バックグラウンド処理は既に実行中です")
149
+ return False
150
+
151
+ try:
152
+ self.is_running = True
153
+ self.stop_event.clear()
154
+
155
+ # 変更点 1: スレッドのターゲットを新しいラッパー関数に変更
156
+ self.background_thread = threading.Thread(
157
+ target=self._thread_entry_point,
158
+ name="BackgroundProcessor",
159
+ daemon=True
160
+ )
161
+ self.background_thread.start()
162
+
163
+ self._setup_signal_handlers()
164
+ logger.info("バックグラウンド処理を開始しました")
165
+ return True
166
+ except Exception as e:
167
+ self.is_running = False
168
+ logger.error(f"バックグラウンド処理の開始に失敗: {e}")
169
+ return False
170
+
171
+ def stop_background_processing(self) -> bool:
172
+ if not self.is_running:
173
+ logger.info("バックグラウンド処理は実行されていません")
174
+ return True
175
+
176
+ try:
177
+ logger.info("バックグラウンド処理の停止を開始します...")
178
+ self.stop_event.set()
179
+ if self.background_thread and self.background_thread.is_alive():
180
+ self.background_thread.join(timeout=10)
181
+ if self.background_thread.is_alive():
182
+ logger.warning("バックグラウンドスレッドの停止がタイムアウトしました")
183
+ return False
184
+
185
+ self.is_running = False
186
+ logger.info("バックグラウンド処理を停止しました")
187
+ return True
188
+ except Exception as e:
189
+ logger.error(f"バックグラウンド処理の停止に失敗: {e}")
190
+ return False
191
+
192
+ # 変更点 2: スレッドのエントリーポイントとなる同期ラッパー関数を追加
193
+ def _thread_entry_point(self) -> None:
194
+ """バックグラウンドスレッド内でイベントループを実行するためのラッパー"""
195
+ loop = asyncio.new_event_loop()
196
+ asyncio.set_event_loop(loop)
197
+ try:
198
+ logger.info("バックグラウンドスレッドのイベントループを開始します。")
199
+ loop.run_until_complete(self._background_loop())
200
+ except Exception as e:
201
+ logger.error(f"バックグラウンドイベントループで致命的なエラー: {e}\n{traceback.format_exc()}")
202
+ finally:
203
+ logger.info("バックグラウンドスレッドのイベントループを終了します。")
204
+ loop.close()
205
+
206
+ # 変更点 3: メインループを async def に変更
207
+ async def _background_loop(self) -> None:
208
+ """バックグラウンド処理の非同期メインループ"""
209
+ logger.info("非同期バックグラウンド処理ループを開始します")
210
+ while not self.stop_event.is_set():
211
+ try:
212
+ current_time = datetime.now()
213
+ current_hour = current_time.hour
214
+ current_date = current_time.strftime("%Y-%m-%d")
215
+
216
+ if current_hour in self.target_hours:
217
+ await self._check_and_run_batch(current_hour, current_date)
218
+
219
+ if current_hour == self.cleanup_hour:
220
+ await self._check_and_run_cleanup(current_date)
221
+
222
+ # 変更点 4: 待機処理を asyncio.sleep に変更
223
+ # stop_eventをチェックしながら1秒ずつ待機することで、素早い停止を可能にする
224
+ for _ in range(self.check_interval):
225
+ if self.stop_event.is_set():
226
+ break
227
+ await asyncio.sleep(1)
228
+
229
+ except Exception as e:
230
+ error_msg = f"バックグラウンド処理ループでエラーが発生: {e}"
231
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
232
+ if self.on_error:
233
+ try:
234
+ self.on_error(error_msg)
235
+ except Exception as callback_error:
236
+ logger.error(f"エラーコールバック実行エラー: {callback_error}")
237
+ await asyncio.sleep(min(self.check_interval * 2, 300))
238
+
239
+ logger.info("非同期バックグラウンド処理ループを終了しました")
240
+
241
+ async def _check_and_run_batch(self, hour: int, date: str) -> None:
242
+ """
243
+ バッチ処理の実行チェックと実行
244
+
245
+ Args:
246
+ hour: 現在の時刻
247
+ date: 現在の日付
248
+ """
249
+ try:
250
+ # 既に今日実行済みかチェック
251
+ last_execution = self.last_execution_times.get(hour)
252
+ if last_execution and last_execution == date:
253
+ return # 既に実行済み
254
+
255
+ logger.info(f"{hour}時のバッチ処理を実行します")
256
+
257
+ # バッチ処理を実行
258
+ result = await self.batch_scheduler.run_hourly_batch(hour)
259
+
260
+ # 実行時刻を記録
261
+ self.last_execution_times[hour] = date
262
+
263
+ # 完了コールバックを呼び出し
264
+ if self.on_batch_complete:
265
+ try:
266
+ self.on_batch_complete(hour, result)
267
+ except Exception as callback_error:
268
+ logger.error(f"バッチ完了コールバック実行エラー: {str(callback_error)}")
269
+
270
+ if result.get("success", False):
271
+ logger.info(f"{hour}時のバッチ処理が完了しました - 処理数: {result.get('processed_count', 0)}")
272
+ else:
273
+ logger.error(f"{hour}時のバッチ処理が失敗しました: {result.get('error', '不明なエラー')}")
274
+
275
+ except Exception as e:
276
+ error_msg = f"{hour}時のバッチ処理チェック中にエラーが発生: {str(e)}"
277
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
278
+
279
+ if self.on_error:
280
+ try:
281
+ self.on_error(error_msg)
282
+ except Exception as callback_error:
283
+ logger.error(f"エラーコールバック実行エラー: {str(callback_error)}")
284
+
285
+ async def _check_and_run_cleanup(self, date: str) -> None:
286
+ """
287
+ クリーンアップ処理の実行チェックと実行
288
+
289
+ Args:
290
+ date: 現在の日付
291
+ """
292
+ try:
293
+ # 既に今日実行済みかチェック
294
+ if self.last_cleanup_date == date:
295
+ return # 既に実行済み
296
+
297
+ logger.info("古いデータのクリーンアップを実行します")
298
+
299
+ # クリーンアップ処理を実行
300
+ result = await self.batch_scheduler.cleanup_old_data(self.cleanup_retention_days)
301
+
302
+ # 実行日を記録
303
+ self.last_cleanup_date = date
304
+
305
+ # 完了コールバックを呼び出し
306
+ if self.on_cleanup_complete:
307
+ try:
308
+ self.on_cleanup_complete(result)
309
+ except Exception as callback_error:
310
+ logger.error(f"クリーンアップ完了コールバック実行エラー: {str(callback_error)}")
311
+
312
+ if result.get("success", False):
313
+ logger.info(f"クリーンアップが完了しました - 削除数: {result.get('deleted_letters', 0)}")
314
+ else:
315
+ logger.error(f"クリーンアップが失敗しました: {result.get('error', '不明なエラー')}")
316
+
317
+ except Exception as e:
318
+ error_msg = f"クリーンアップ処理チェック中にエラーが発生: {str(e)}"
319
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
320
+
321
+ if self.on_error:
322
+ try:
323
+ self.on_error(error_msg)
324
+ except Exception as callback_error:
325
+ logger.error(f"エラーコールバック実行エラー: {str(callback_error)}")
326
+
327
+ def _setup_signal_handlers(self) -> None:
328
+ """シグナルハンドラーを設定"""
329
+ def signal_handler(signum, frame):
330
+ logger.info(f"シグナル {signum} を受信しました。バックグラウンド処理を停止します...")
331
+ self.stop_background_processing()
332
+ sys.exit(0)
333
+
334
+ # SIGTERM と SIGINT のハンドラーを設定
335
+ signal.signal(signal.SIGTERM, signal_handler)
336
+ signal.signal(signal.SIGINT, signal_handler)
337
+
338
+ def get_status(self) -> Dict[str, Any]:
339
+ """
340
+ バックグラウンド処理の状態を取得
341
+
342
+ Returns:
343
+ Dict: 状態情報
344
+ """
345
+ return {
346
+ "is_running": self.is_running,
347
+ "enable_background_processing": self.enable_background_processing,
348
+ "target_hours": self.target_hours,
349
+ "check_interval": self.check_interval,
350
+ "cleanup_hour": self.cleanup_hour,
351
+ "cleanup_retention_days": self.cleanup_retention_days,
352
+ "last_execution_times": self.last_execution_times.copy(),
353
+ "last_cleanup_date": self.last_cleanup_date,
354
+ "thread_alive": self.background_thread.is_alive() if self.background_thread else False,
355
+ "current_time": datetime.now().isoformat()
356
+ }
357
+
358
+ async def force_run_batch(self, hour: int) -> Dict[str, Any]:
359
+ """
360
+ 指定時刻のバッチ処理を強制実行
361
+
362
+ Args:
363
+ hour: 実行対象の時刻
364
+
365
+ Returns:
366
+ Dict: 実行結果
367
+ """
368
+ try:
369
+ if hour not in self.target_hours:
370
+ return {
371
+ "success": False,
372
+ "error": f"無効な時刻が指定されました: {hour} (有効: {self.target_hours})"
373
+ }
374
+
375
+ logger.info(f"{hour}時のバッチ処理を強制実行します")
376
+
377
+ result = await self.batch_scheduler.run_hourly_batch(hour)
378
+
379
+ # 実行時刻を記録
380
+ current_date = datetime.now().strftime("%Y-%m-%d")
381
+ self.last_execution_times[hour] = current_date
382
+
383
+ return result
384
+
385
+ except Exception as e:
386
+ error_msg = f"バッチ処理の強制実行中にエラーが発生: {str(e)}"
387
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
388
+ return {
389
+ "success": False,
390
+ "error": error_msg
391
+ }
392
+
393
+ async def force_run_cleanup(self) -> Dict[str, Any]:
394
+ """
395
+ クリーンアップ処理を強制実行
396
+
397
+ Returns:
398
+ Dict: 実行結果
399
+ """
400
+ try:
401
+ logger.info("クリーンアップ処理を強制実行します")
402
+
403
+ result = await self.batch_scheduler.cleanup_old_data(self.cleanup_retention_days)
404
+
405
+ # 実行日を記録
406
+ current_date = datetime.now().strftime("%Y-%m-%d")
407
+ self.last_cleanup_date = current_date
408
+
409
+ return result
410
+
411
+ except Exception as e:
412
+ error_msg = f"クリーンアップ処理の強制実行中にエラーが発生: {str(e)}"
413
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
414
+ return {
415
+ "success": False,
416
+ "error": error_msg
417
+ }
418
+
419
+ def set_callbacks(self,
420
+ on_batch_complete: Optional[Callable] = None,
421
+ on_cleanup_complete: Optional[Callable] = None,
422
+ on_error: Optional[Callable] = None) -> None:
423
+ """
424
+ コールバック関数を設定
425
+
426
+ Args:
427
+ on_batch_complete: バッチ処理完了時のコールバック
428
+ on_cleanup_complete: クリーンアップ完了時のコールバック
429
+ on_error: エラー発生時のコールバック
430
+ """
431
+ self.on_batch_complete = on_batch_complete
432
+ self.on_cleanup_complete = on_cleanup_complete
433
+ self.on_error = on_error
434
+
435
+ logger.info("コールバック関数を設定しました")
436
+
437
+ async def get_processing_statistics(self, days: int = 7) -> Dict[str, Any]:
438
+ """
439
+ 処理統計情報を取得
440
+
441
+ Args:
442
+ days: 統計対象日数
443
+
444
+ Returns:
445
+ Dict: 統計情報
446
+ """
447
+ try:
448
+ # バッチスケジューラーから統計を取得
449
+ batch_stats = await self.batch_scheduler.get_batch_statistics(days)
450
+
451
+ # ストレージ統計を取得
452
+ storage_stats = await self.storage_manager.get_storage_stats()
453
+
454
+ # バックグラウンド処理の状態を追加
455
+ status = self.get_status()
456
+
457
+ return {
458
+ "background_processor": status,
459
+ "batch_statistics": batch_stats,
460
+ "storage_statistics": storage_stats,
461
+ "generated_at": datetime.now().isoformat()
462
+ }
463
+
464
+ except Exception as e:
465
+ logger.error(f"統計情報取得エラー: {str(e)}")
466
+ return {"error": str(e)}
467
+
468
+ def __enter__(self):
469
+ """コンテキストマネージャーのエントリー"""
470
+ self.start_background_processing()
471
+ return self
472
+
473
+ def __exit__(self, exc_type, exc_val, exc_tb):
474
+ """コンテキストマネージャーの終了"""
475
+ self.stop_background_processing()
476
+
477
+
478
+ class StreamlitBackgroundIntegration:
479
+ """Streamlitアプリとバックグラウンド処理の統合クラス"""
480
+
481
+ def __init__(self):
482
+ self.background_processor = None
483
+ self.is_initialized = False
484
+
485
+ def initialize(self, storage_manager: Optional[AsyncStorageManager] = None) -> bool:
486
+ """
487
+ バックグラウンド処理を初期化
488
+
489
+ Args:
490
+ storage_manager: ストレージマネージャー
491
+
492
+ Returns:
493
+ bool: 初期化成功フラグ
494
+ """
495
+ try:
496
+ if self.is_initialized:
497
+ return True
498
+
499
+ self.background_processor = BackgroundProcessor(storage_manager)
500
+
501
+ # コールバック関数を設定
502
+ self.background_processor.set_callbacks(
503
+ on_batch_complete=self._on_batch_complete,
504
+ on_cleanup_complete=self._on_cleanup_complete,
505
+ on_error=self._on_error
506
+ )
507
+
508
+ # バックグラウンド処理を開始
509
+ success = self.background_processor.start_background_processing()
510
+
511
+ if success:
512
+ self.is_initialized = True
513
+ logger.info("Streamlitバックグラウンド統合を初期化しました")
514
+
515
+ return success
516
+
517
+ except Exception as e:
518
+ logger.error(f"バックグラウンド統合の初期化に失敗: {str(e)}")
519
+ return False
520
+
521
+ def shutdown(self) -> bool:
522
+ """
523
+ バックグラウンド処理を終了
524
+
525
+ Returns:
526
+ bool: 終了成功フラグ
527
+ """
528
+ try:
529
+ if not self.is_initialized or not self.background_processor:
530
+ return True
531
+
532
+ success = self.background_processor.stop_background_processing()
533
+
534
+ if success:
535
+ self.is_initialized = False
536
+ logger.info("Streamlitバックグラウンド統合を終了しました")
537
+
538
+ return success
539
+
540
+ except Exception as e:
541
+ logger.error(f"バックグラウンド統合の終了に失敗: {str(e)}")
542
+ return False
543
+
544
+ def get_status(self) -> Dict[str, Any]:
545
+ """統合状態を取得"""
546
+ if not self.is_initialized or not self.background_processor:
547
+ return {"initialized": False, "running": False}
548
+
549
+ status = self.background_processor.get_status()
550
+ status["initialized"] = self.is_initialized
551
+
552
+ return status
553
+
554
+ async def get_statistics(self, days: int = 7) -> Dict[str, Any]:
555
+ """統計情報を取得"""
556
+ if not self.is_initialized or not self.background_processor:
557
+ return {"error": "バックグラウンド処理が初期化されていません"}
558
+
559
+ return await self.background_processor.get_processing_statistics(days)
560
+
561
+ def _on_batch_complete(self, hour: int, result: Dict[str, Any]) -> None:
562
+ """バッチ処理完了時のコールバック"""
563
+ logger.info(f"バッチ処理完了通知 - {hour}時: {result.get('success', False)}")
564
+ # Streamlitの状態更新やキャッシュクリアなどを実装可能
565
+
566
+ def _on_cleanup_complete(self, result: Dict[str, Any]) -> None:
567
+ """クリーンアップ完了時のコールバック"""
568
+ logger.info(f"クリーンアップ完了通知: {result.get('success', False)}")
569
+ # Streamlitの状態更新やキャッシュクリアなどを実装可能
570
+
571
+ def _on_error(self, error_message: str) -> None:
572
+ """エラー発生時のコールバック"""
573
+ logger.error(f"バックグラウンド処理エラー通知: {error_message}")
574
+ # Streamlitのエラー表示やアラート機能を実装可能
575
+
576
+
577
+ # グローバルインスタンス(Streamlitアプリで使用)
578
+ streamlit_background = StreamlitBackgroundIntegration()
579
+
580
+
581
+ # テスト用の関数
582
+ async def test_background_processor():
583
+ """BackgroundProcessorのテスト"""
584
+ import tempfile
585
+
586
+ # 一時ディレクトリでテスト
587
+ with tempfile.TemporaryDirectory() as temp_dir:
588
+ test_file = os.path.join(temp_dir, "test_letters.json")
589
+ storage = AsyncStorageManager(test_file)
590
+
591
+ print("=== BackgroundProcessorテスト開始 ===")
592
+
593
+ # バックグラウンドプロセッサーのテスト
594
+ processor = BackgroundProcessor(storage)
595
+
596
+ # 状態確認テスト
597
+ status = processor.get_status()
598
+ print(f"✓ 状態確認テスト: {status['is_running']}")
599
+
600
+ # 強制バッチ実行テスト
601
+ batch_result = await processor.force_run_batch(2)
602
+ print(f"✓ 強制バッチ実行テスト: {batch_result['success']}")
603
+
604
+ # 強制クリーンアップテスト
605
+ cleanup_result = await processor.force_run_cleanup()
606
+ print(f"✓ 強制クリーンアップテスト: {cleanup_result['success']}")
607
+
608
+ # 統計情報テスト
609
+ stats = await processor.get_processing_statistics()
610
+ print(f"✓ 統計情報取得テスト: {'error' not in stats}")
611
+
612
+ print("=== 全てのテストが完了しました! ===")
613
+
614
+
615
+ if __name__ == "__main__":
616
+ asyncio.run(test_background_processor())
batch_scheduler.py ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ バッチスケジューラークラス
3
+ 2時、3時、4時の時刻別バッチ処理機能を実装し、
4
+ 指定時刻の未処理リクエストを処理する機能を提供します。
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict, Any, List, Optional
11
+ import logging
12
+ import traceback
13
+
14
+ from letter_request_manager import RequestManager
15
+ from letter_generator import LetterGenerator
16
+ from async_storage_manager import AsyncStorageManager
17
+ from async_rate_limiter import AsyncRateLimitManager
18
+ from letter_user_manager import UserManager
19
+
20
+ # ログ設定
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class BatchSchedulerError(Exception):
26
+ """バッチスケジューラー関連のエラー"""
27
+ pass
28
+
29
+
30
+ class BatchScheduler:
31
+ """非同期バッチ処理スケジューラー"""
32
+
33
+ def __init__(self, storage_manager: Optional[AsyncStorageManager] = None):
34
+ """
35
+ バッチスケジューラーを初期化
36
+
37
+ Args:
38
+ storage_manager: ストレージマネージャー(指定しない場合は新規作成)
39
+ """
40
+ # ストレージマネージャーの初期化
41
+ if storage_manager is None:
42
+ storage_path = os.getenv("STORAGE_PATH", "/mnt/data/letters.json")
43
+ self.storage_manager = AsyncStorageManager(storage_path)
44
+ else:
45
+ self.storage_manager = storage_manager
46
+
47
+ # 依存コンポーネントの初期化
48
+ self.rate_limiter = AsyncRateLimitManager(self.storage_manager)
49
+ self.request_manager = RequestManager(self.storage_manager, self.rate_limiter)
50
+ self.letter_generator = LetterGenerator()
51
+ self.user_manager = UserManager(self.storage_manager)
52
+
53
+ # 設定値
54
+ self.available_hours = [2, 3, 4] # 2時、3時、4時
55
+ self.max_concurrent_generations = int(os.getenv("MAX_CONCURRENT_GENERATIONS", "3"))
56
+ self.generation_timeout = int(os.getenv("GENERATION_TIMEOUT", "300")) # 5分
57
+ self.retry_failed_requests = os.getenv("RETRY_FAILED_REQUESTS", "true").lower() == "true"
58
+
59
+ logger.info(f"BatchScheduler初期化完了 - 対象時刻: {self.available_hours}")
60
+
61
+ async def run_hourly_batch(self, hour: int) -> Dict[str, Any]:
62
+ """
63
+ 指定時刻のバッチ処理を実行
64
+
65
+ Args:
66
+ hour: 実行時刻(2, 3, 4のいずれか)
67
+
68
+ Returns:
69
+ Dict: バッチ処理の結果
70
+ """
71
+ start_time = datetime.now()
72
+ batch_id = f"{start_time.strftime('%Y%m%d_%H%M%S')}_{hour}"
73
+
74
+ logger.info(f"=== {hour}時のバッチ処理開始 (ID: {batch_id}) ===")
75
+
76
+ # 時刻の検証
77
+ if hour not in self.available_hours:
78
+ error_msg = f"無効な時刻が指定されました: {hour}時 (有効: {self.available_hours})"
79
+ logger.error(error_msg)
80
+ return {
81
+ "success": False,
82
+ "error": error_msg,
83
+ "batch_id": batch_id,
84
+ "hour": hour,
85
+ "start_time": start_time.isoformat(),
86
+ "end_time": datetime.now().isoformat()
87
+ }
88
+
89
+ try:
90
+ # バッチ実行の記録開始
91
+ await self._record_batch_start(batch_id, hour, start_time)
92
+
93
+ # 指定時刻の未処理リクエストを処理
94
+ result = await self.process_pending_requests_for_hour(hour)
95
+
96
+ # バッチ実行の記録完了
97
+ end_time = datetime.now()
98
+ await self._record_batch_completion(batch_id, hour, start_time, end_time, result)
99
+
100
+ execution_time = (end_time - start_time).total_seconds()
101
+
102
+ logger.info(f"=== {hour}時のバッチ処理完了 (所要時間: {execution_time:.2f}秒) ===")
103
+
104
+ return {
105
+ "success": True,
106
+ "batch_id": batch_id,
107
+ "hour": hour,
108
+ "start_time": start_time.isoformat(),
109
+ "end_time": end_time.isoformat(),
110
+ "execution_time": execution_time,
111
+ "processed_count": result.get("processed_count", 0),
112
+ "success_count": result.get("success_count", 0),
113
+ "failed_count": result.get("failed_count", 0),
114
+ "errors": result.get("errors", [])
115
+ }
116
+
117
+ except Exception as e:
118
+ end_time = datetime.now()
119
+ execution_time = (end_time - start_time).total_seconds()
120
+ error_msg = f"バッチ処理中にエラーが発生しました: {str(e)}"
121
+
122
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
123
+
124
+ # エラーの記録
125
+ await self._record_batch_error(batch_id, hour, start_time, end_time, error_msg)
126
+
127
+ return {
128
+ "success": False,
129
+ "error": error_msg,
130
+ "batch_id": batch_id,
131
+ "hour": hour,
132
+ "start_time": start_time.isoformat(),
133
+ "end_time": end_time.isoformat(),
134
+ "execution_time": execution_time
135
+ }
136
+
137
+ async def process_pending_requests_for_hour(self, hour: int) -> Dict[str, Any]:
138
+ """
139
+ 指定時刻の未処理リクエストを処理
140
+
141
+ Args:
142
+ hour: 処理対象の時刻
143
+
144
+ Returns:
145
+ Dict: 処理結果の詳細
146
+ """
147
+ try:
148
+ # 未処理リクエストを取得
149
+ pending_requests = await self.request_manager.get_pending_requests_by_hour(hour)
150
+
151
+ if not pending_requests:
152
+ logger.info(f"{hour}時の未処理リクエストはありません")
153
+ return {
154
+ "processed_count": 0,
155
+ "success_count": 0,
156
+ "failed_count": 0,
157
+ "errors": []
158
+ }
159
+
160
+ logger.info(f"{hour}時の未処理リクエスト数: {len(pending_requests)}")
161
+
162
+ # 並行処理用のセマフォ
163
+ semaphore = asyncio.Semaphore(self.max_concurrent_generations)
164
+
165
+ # 各リクエストを並行処理
166
+ tasks = []
167
+ for request in pending_requests:
168
+ task = self._process_single_request(semaphore, request)
169
+ tasks.append(task)
170
+
171
+ # 全てのタスクを実行
172
+ results = await asyncio.gather(*tasks, return_exceptions=True)
173
+
174
+ # 結果を集計
175
+ success_count = 0
176
+ failed_count = 0
177
+ errors = []
178
+
179
+ for i, result in enumerate(results):
180
+ if isinstance(result, Exception):
181
+ failed_count += 1
182
+ error_msg = f"リクエスト処理例外: {str(result)}"
183
+ errors.append({
184
+ "request_index": i,
185
+ "user_id": pending_requests[i].get("user_id", "unknown"),
186
+ "error": error_msg
187
+ })
188
+ logger.error(error_msg)
189
+ elif result.get("success", False):
190
+ success_count += 1
191
+ else:
192
+ failed_count += 1
193
+ errors.append({
194
+ "request_index": i,
195
+ "user_id": pending_requests[i].get("user_id", "unknown"),
196
+ "error": result.get("error", "不明なエラー")
197
+ })
198
+
199
+ logger.info(f"処理完了 - 成功: {success_count}, 失敗: {failed_count}")
200
+
201
+ return {
202
+ "processed_count": len(pending_requests),
203
+ "success_count": success_count,
204
+ "failed_count": failed_count,
205
+ "errors": errors
206
+ }
207
+
208
+ except Exception as e:
209
+ error_msg = f"未処理リクエスト処理中にエラーが発生: {str(e)}"
210
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
211
+ return {
212
+ "processed_count": 0,
213
+ "success_count": 0,
214
+ "failed_count": 0,
215
+ "errors": [{"error": error_msg}]
216
+ }
217
+
218
+ async def _process_single_request(self, semaphore: asyncio.Semaphore, request: Dict[str, Any]) -> Dict[str, Any]:
219
+ """
220
+ 単一のリクエストを処理
221
+
222
+ Args:
223
+ semaphore: 並行処理制御用のセマフォ
224
+ request: 処理対象のリクエスト
225
+
226
+ Returns:
227
+ Dict: 処理結果
228
+ """
229
+ async with semaphore:
230
+ user_id = request["user_id"]
231
+ theme = request["theme"]
232
+ date = request["date"]
233
+
234
+ try:
235
+ logger.info(f"手紙生成開始 - ユーザー: {user_id}, テーマ: {theme[:50]}...")
236
+
237
+ # タイムアウト付きで手紙生成を実行
238
+ user_history = await self.user_manager.get_user_profile(user_id)
239
+
240
+ generation_task = self.letter_generator.generate_letter(user_id, theme, user_history)
241
+ letter_result = await asyncio.wait_for(generation_task, timeout=self.generation_timeout)
242
+
243
+ # 生成された手紙をストレージに保存
244
+ await self._save_generated_letter(user_id, date, theme, letter_result)
245
+
246
+ # リクエストを完了としてマーク
247
+ await self.request_manager.mark_request_processed(user_id, date, "completed")
248
+
249
+ # ユーザー履歴を更新
250
+ await self.user_manager.update_user_history(user_id, {
251
+ "date": date,
252
+ "theme": theme,
253
+ "status": "completed",
254
+ "generated_at": datetime.now().isoformat()
255
+ })
256
+
257
+ logger.info(f"手紙生成完了 - ユーザー: {user_id}")
258
+
259
+ return {
260
+ "success": True,
261
+ "user_id": user_id,
262
+ "theme": theme,
263
+ "generation_time": letter_result["metadata"].get("generation_time", 0)
264
+ }
265
+
266
+ except asyncio.TimeoutError:
267
+ error_msg = f"手紙生成がタイムアウトしました({self.generation_timeout}秒)"
268
+ logger.error(f"{error_msg} - ユーザー: {user_id}")
269
+
270
+ await self.request_manager.mark_request_failed(user_id, date, error_msg)
271
+
272
+ return {
273
+ "success": False,
274
+ "user_id": user_id,
275
+ "error": error_msg
276
+ }
277
+
278
+ except Exception as e:
279
+ error_msg = f"手紙生成中にエラーが発生: {str(e)}"
280
+ logger.error(f"{error_msg} - ユーザー: {user_id}\n{traceback.format_exc()}")
281
+
282
+ await self.request_manager.mark_request_failed(user_id, date, error_msg)
283
+
284
+ return {
285
+ "success": False,
286
+ "user_id": user_id,
287
+ "error": error_msg
288
+ }
289
+
290
+ async def _save_generated_letter(self, user_id: str, date: str, theme: str, letter_result: Dict[str, Any]) -> None:
291
+ """
292
+ 生成された手紙をストレージに保存
293
+
294
+ Args:
295
+ user_id: ユーザーID
296
+ date: 日付
297
+ theme: テーマ
298
+ letter_result: 生成結果
299
+ """
300
+ try:
301
+ user_data = await self.storage_manager.get_user_data(user_id)
302
+
303
+ # 手紙データを作成
304
+ letter_data = {
305
+ "theme": theme,
306
+ "content": letter_result["content"],
307
+ "status": "completed",
308
+ "generated_at": datetime.now().isoformat(),
309
+ "metadata": letter_result["metadata"]
310
+ }
311
+
312
+ # ユーザーデータに追加
313
+ user_data["letters"][date] = letter_data
314
+ user_data["profile"]["total_letters"] = user_data["profile"].get("total_letters", 0) + 1
315
+ user_data["profile"]["last_request"] = date
316
+
317
+ # ストレージに保存
318
+ await self.storage_manager.update_user_data(user_id, user_data)
319
+
320
+ logger.info(f"手紙をストレージに保存しました - ユーザー: {user_id}, 日付: {date}")
321
+
322
+ except Exception as e:
323
+ logger.error(f"手紙保存エラー - ユーザー: {user_id}, 日付: {date}: {str(e)}")
324
+ raise
325
+
326
+ async def _record_batch_start(self, batch_id: str, hour: int, start_time: datetime) -> None:
327
+ """バッチ実行開始を記録"""
328
+ try:
329
+ system_info = await self.storage_manager.get_system_info()
330
+
331
+ if "batch_runs" not in system_info:
332
+ system_info["batch_runs"] = {}
333
+
334
+ system_info["batch_runs"][batch_id] = {
335
+ "hour": hour,
336
+ "start_time": start_time.isoformat(),
337
+ "status": "running"
338
+ }
339
+
340
+ await self.storage_manager.update_system_info(system_info)
341
+
342
+ except Exception as e:
343
+ logger.error(f"バッチ開始記録エラー: {str(e)}")
344
+
345
+ async def _record_batch_completion(self, batch_id: str, hour: int, start_time: datetime,
346
+ end_time: datetime, result: Dict[str, Any]) -> None:
347
+ """バッチ実行完了を記録"""
348
+ try:
349
+ system_info = await self.storage_manager.get_system_info()
350
+
351
+ if batch_id in system_info.get("batch_runs", {}):
352
+ system_info["batch_runs"][batch_id].update({
353
+ "end_time": end_time.isoformat(),
354
+ "status": "completed",
355
+ "execution_time": (end_time - start_time).total_seconds(),
356
+ "processed_count": result.get("processed_count", 0),
357
+ "success_count": result.get("success_count", 0),
358
+ "failed_count": result.get("failed_count", 0),
359
+ "error_count": len(result.get("errors", []))
360
+ })
361
+
362
+ await self.storage_manager.update_system_info(system_info)
363
+
364
+ except Exception as e:
365
+ logger.error(f"バッチ完了記録エラー: {str(e)}")
366
+
367
+ async def _record_batch_error(self, batch_id: str, hour: int, start_time: datetime,
368
+ end_time: datetime, error_msg: str) -> None:
369
+ """バッチ実行エラーを記録"""
370
+ try:
371
+ system_info = await self.storage_manager.get_system_info()
372
+
373
+ if batch_id in system_info.get("batch_runs", {}):
374
+ system_info["batch_runs"][batch_id].update({
375
+ "end_time": end_time.isoformat(),
376
+ "status": "failed",
377
+ "execution_time": (end_time - start_time).total_seconds(),
378
+ "error": error_msg
379
+ })
380
+
381
+ await self.storage_manager.update_system_info(system_info)
382
+
383
+ except Exception as e:
384
+ logger.error(f"バッチエラー記録エラー: {str(e)}")
385
+
386
+ def schedule_all_hours(self) -> None:
387
+ """
388
+ 全ての対象時刻でのスケジュール設定
389
+ 注意: この関数は実際のスケジューリングライブラリと組み合わせて使用する
390
+ """
391
+ logger.info("バッチスケジュールを設定します")
392
+
393
+ for hour in self.available_hours:
394
+ logger.info(f" - {hour}:00 に手紙生成バッチを設定")
395
+
396
+ # 実際のスケジューリングは外部ライブラリ(APScheduler等)で実装
397
+ # ここでは設定情報のログ出力のみ
398
+ logger.info("スケジュール設定完了(実際の実行は外部スケジューラーが必要)")
399
+
400
+ async def cleanup_old_data(self, days: int = 90) -> Dict[str, Any]:
401
+ """
402
+ 古いデータの自動削除
403
+
404
+ Args:
405
+ days: 保持日数
406
+
407
+ Returns:
408
+ Dict: 削除結果
409
+ """
410
+ try:
411
+ logger.info(f"{days}日以前の古いデータを削除します")
412
+
413
+ # ストレージマネージャーの削除機能を使用
414
+ deleted_count = await self.storage_manager.cleanup_old_data(days)
415
+
416
+ # リクエストマネージャーの削除機能も使用
417
+ deleted_requests = await self.request_manager.cleanup_old_requests(days)
418
+
419
+ # バックアップの作成
420
+ backup_path = await self.storage_manager.backup_data()
421
+
422
+ result = {
423
+ "success": True,
424
+ "deleted_letters": deleted_count,
425
+ "deleted_requests": deleted_requests,
426
+ "backup_created": backup_path,
427
+ "cleanup_date": datetime.now().isoformat()
428
+ }
429
+
430
+ logger.info(f"古いデータ削除完了: {result}")
431
+ return result
432
+
433
+ except Exception as e:
434
+ error_msg = f"古いデータ削除中にエラーが発生: {str(e)}"
435
+ logger.error(f"{error_msg}\n{traceback.format_exc()}")
436
+
437
+ return {
438
+ "success": False,
439
+ "error": error_msg,
440
+ "cleanup_date": datetime.now().isoformat()
441
+ }
442
+
443
+ async def get_batch_statistics(self, days: int = 7) -> Dict[str, Any]:
444
+ """
445
+ バッチ処理の統計情報を取得
446
+
447
+ Args:
448
+ days: 統計対象日数
449
+
450
+ Returns:
451
+ Dict: 統計情報
452
+ """
453
+ try:
454
+ system_info = await self.storage_manager.get_system_info()
455
+ batch_runs = system_info.get("batch_runs", {})
456
+
457
+ # 指定日数以内のバッチ実行を抽出
458
+ cutoff_date = datetime.now() - timedelta(days=days)
459
+ recent_batches = []
460
+
461
+ for batch_id, batch_info in batch_runs.items():
462
+ try:
463
+ start_time = datetime.fromisoformat(batch_info["start_time"])
464
+ if start_time >= cutoff_date:
465
+ recent_batches.append(batch_info)
466
+ except (ValueError, KeyError):
467
+ continue
468
+
469
+ # 統計を計算
470
+ total_runs = len(recent_batches)
471
+ successful_runs = len([b for b in recent_batches if b.get("status") == "completed"])
472
+ failed_runs = len([b for b in recent_batches if b.get("status") == "failed"])
473
+
474
+ total_processed = sum(b.get("processed_count", 0) for b in recent_batches)
475
+ total_success = sum(b.get("success_count", 0) for b in recent_batches)
476
+ total_failed = sum(b.get("failed_count", 0) for b in recent_batches)
477
+
478
+ avg_execution_time = 0
479
+ if recent_batches:
480
+ execution_times = [b.get("execution_time", 0) for b in recent_batches if b.get("execution_time")]
481
+ if execution_times:
482
+ avg_execution_time = sum(execution_times) / len(execution_times)
483
+
484
+ # 時刻別統計
485
+ hourly_stats = {hour: {"runs": 0, "processed": 0, "success": 0} for hour in self.available_hours}
486
+
487
+ for batch in recent_batches:
488
+ hour = batch.get("hour")
489
+ if hour in hourly_stats:
490
+ hourly_stats[hour]["runs"] += 1
491
+ hourly_stats[hour]["processed"] += batch.get("processed_count", 0)
492
+ hourly_stats[hour]["success"] += batch.get("success_count", 0)
493
+
494
+ return {
495
+ "period_days": days,
496
+ "total_runs": total_runs,
497
+ "successful_runs": successful_runs,
498
+ "failed_runs": failed_runs,
499
+ "success_rate": (successful_runs / total_runs * 100) if total_runs > 0 else 0,
500
+ "total_processed": total_processed,
501
+ "total_success": total_success,
502
+ "total_failed": total_failed,
503
+ "processing_success_rate": (total_success / total_processed * 100) if total_processed > 0 else 0,
504
+ "avg_execution_time": avg_execution_time,
505
+ "hourly_stats": hourly_stats,
506
+ "generated_at": datetime.now().isoformat()
507
+ }
508
+
509
+ except Exception as e:
510
+ logger.error(f"統計情報取得エラー: {str(e)}")
511
+ return {"error": str(e)}
512
+
513
+ async def test_batch_processing(self, test_hour: int = 2) -> Dict[str, Any]:
514
+ """
515
+ バッチ処理のテスト実行
516
+
517
+ Args:
518
+ test_hour: テスト対象の時刻
519
+
520
+ Returns:
521
+ Dict: テスト結果
522
+ """
523
+ try:
524
+ logger.info(f"バッチ処理テストを開始します({test_hour}時)")
525
+
526
+ # API接続テスト
527
+ api_status = await self.letter_generator.check_api_connections()
528
+ if not api_status.get("overall", False):
529
+ return {
530
+ "success": False,
531
+ "error": "API接続テストに失敗しました",
532
+ "api_status": api_status
533
+ }
534
+
535
+ # テスト用のバッチ処理を実行
536
+ result = await self.run_hourly_batch(test_hour)
537
+
538
+ return {
539
+ "success": True,
540
+ "message": "バッチ処理テスト完了",
541
+ "api_status": api_status,
542
+ "batch_result": result
543
+ }
544
+
545
+ except Exception as e:
546
+ return {
547
+ "success": False,
548
+ "error": f"バッチ処理テスト中にエラーが発生: {str(e)}"
549
+ }
550
+
551
+
552
+ # テスト用の関数
553
+ async def test_batch_scheduler():
554
+ """BatchSchedulerのテスト"""
555
+ import tempfile
556
+ import uuid
557
+
558
+ # 一時ディレクトリでテスト
559
+ with tempfile.TemporaryDirectory() as temp_dir:
560
+ test_file = os.path.join(temp_dir, "test_letters.json")
561
+ storage = AsyncStorageManager(test_file)
562
+ scheduler = BatchScheduler(storage)
563
+
564
+ print("=== BatchSchedulerテスト開始 ===")
565
+
566
+ # テスト用のリクエストを作成
567
+ user_id = str(uuid.uuid4())
568
+ success, message = await scheduler.request_manager.submit_request(user_id, "テストテーマ", 2)
569
+ print(f"✓ テストリクエスト作成: {success} - {message}")
570
+
571
+ # バッチ処理テスト
572
+ result = await scheduler.run_hourly_batch(2)
573
+ print(f"✓ バッチ処理テスト: {result['success']}")
574
+
575
+ # 統計情報テスト
576
+ stats = await scheduler.get_batch_statistics()
577
+ print(f"✓ 統計情報取得テスト: {stats}")
578
+
579
+ # 古いデータ削除テスト
580
+ cleanup_result = await scheduler.cleanup_old_data(0) # 全て削除
581
+ print(f"✓ データ削除テスト: {cleanup_result['success']}")
582
+
583
+ print("=== 全てのテストが完了しました! ===")
584
+
585
+
586
+ if __name__ == "__main__":
587
+ asyncio.run(test_batch_scheduler())
bijyutukann-yoru.jpg ADDED

Git LFS Details

  • SHA256: 7afd304f7dea03c30105f56cb31ef990c298a6a4324d72c3b46ac99fb48514fb
  • Pointer size: 131 Bytes
  • Size of remote file: 253 kB
components_chat_interface.py ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ チャットインターフェースコンポーネント
3
+ Streamlitのチャット機能を使用したメッセージ表示と入力処理
4
+ マスクアイコンとフリップアニメーション機能を含む
5
+ """
6
+ import streamlit as st
7
+ import logging
8
+ import re
9
+ import uuid
10
+ from typing import List, Dict, Optional, Tuple
11
+ from datetime import datetime
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class ChatInterface:
16
+ """チャットインターフェースを管理するクラス"""
17
+
18
+ def __init__(self, max_input_length: int = 1000):
19
+ """
20
+ Args:
21
+ max_input_length: 入力メッセージの最大長
22
+ """
23
+ self.max_input_length = max_input_length
24
+
25
+ def render_chat_history(self, messages: List[Dict[str, str]],
26
+ memory_summary: str = "") -> None:
27
+ """
28
+ チャット履歴を表示する(マスク機能付き、最適化版)
29
+
30
+ Args:
31
+ messages: チャットメッセージのリスト
32
+ memory_summary: メモリサマリー(重要単語から生成)
33
+ """
34
+ logger.info(f"🎯 render_chat_history 開始: {len(messages) if messages else 0}件のメッセージ")
35
+ try:
36
+ # 初期メッセージの存在確認と復元
37
+ if messages:
38
+ initial_messages = [msg for msg in messages if msg.get('is_initial', False)]
39
+ if not initial_messages:
40
+ logger.warning("初期メッセージが見つかりません - 復元を試行")
41
+ # 初期メッセージが存在しない場合は先頭に追加
42
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
43
+ messages.insert(0, initial_message)
44
+ logger.info("初期メッセージを復元しました")
45
+
46
+ # 履歴表示の重複実行を防ぐ(改良版)
47
+ # セッション固有のレンダリング状態を管理
48
+ if 'chat_render_state' not in st.session_state:
49
+ st.session_state.chat_render_state = {
50
+ 'last_messages_hash': None,
51
+ 'render_count': 0,
52
+ 'last_render_time': 0
53
+ }
54
+
55
+ # メッセージ内容とポチモード状態を含むハッシュを生成
56
+ show_all_hidden = st.session_state.get('show_all_hidden', False)
57
+ messages_with_state = {
58
+ 'messages': [{'role': msg.get('role'), 'content': msg.get('content', '')[:100]} for msg in messages], # 内容を短縮してハッシュ計算を軽量化
59
+ 'show_all_hidden': show_all_hidden,
60
+ 'message_count': len(messages)
61
+ }
62
+
63
+ import time
64
+ current_time = time.time()
65
+ messages_hash = hash(str(messages_with_state))
66
+ last_render_hash = st.session_state.chat_render_state['last_messages_hash']
67
+
68
+ # 短時間での連続レンダリングを防止(0.5秒以内の再レンダリングを制限)
69
+ time_since_last_render = current_time - st.session_state.chat_render_state['last_render_time']
70
+ if time_since_last_render < 0.5 and last_render_hash == messages_hash:
71
+ logger.debug(f"短時間での重複レンダリングをスキップ({time_since_last_render:.2f}秒前)")
72
+ return
73
+
74
+ # 強制表示条件をより厳密に制御
75
+ force_render_conditions = [
76
+ st.session_state.get('tutorial_start_requested', False),
77
+ st.session_state.get('tutorial_skip_requested', False),
78
+ st.session_state.get('show_all_hidden_changed', False),
79
+ st.session_state.get('force_chat_rerender', False) # 明示的な強制レンダリングフラグ
80
+ ]
81
+
82
+ should_force_render = any(force_render_conditions)
83
+
84
+ # ハッシュが同じで強制表示条件もない場合はスキップ
85
+ if last_render_hash == messages_hash and not should_force_render:
86
+ logger.debug("チャット履歴表示をスキップ(変更なし)")
87
+ return
88
+
89
+ # レンダリング実行(ログ削除で軽量化)
90
+
91
+ # レンダリング状態を更新
92
+ st.session_state.chat_render_state['last_messages_hash'] = messages_hash
93
+ st.session_state.chat_render_state['render_count'] += 1
94
+ st.session_state.chat_render_state['last_render_time'] = current_time
95
+
96
+ # メモリサマリーがある場合は表示
97
+ if memory_summary:
98
+ with st.expander("💭 過去の会話の記憶", expanded=False):
99
+ st.info(memory_summary)
100
+
101
+ # 独自のチャット表示(st.chat_messageを使わない安定版)
102
+ if not messages:
103
+ st.info("まだメッセージがありません。下のチャット欄で麻理に話しかけてみてください。")
104
+ return
105
+
106
+ for i, message in enumerate(messages):
107
+ role = message.get("role", "user")
108
+ content = message.get("content", "")
109
+ timestamp = message.get("timestamp")
110
+ is_initial = message.get("is_initial", False)
111
+ message_id = message.get("message_id", f"msg_{i}")
112
+
113
+ # 独自のチャットバブル表示
114
+ self._render_custom_chat_bubble(role, content, is_initial, message_id, timestamp)
115
+
116
+ # 履歴表示完了をマーク
117
+ # 既にレンダリング状態は上で更新済み
118
+
119
+ logger.debug(f"チャット履歴表示完了({len(messages)}件)")
120
+
121
+ # 強制表示フラグをクリア(表示完了後に実行)
122
+ if st.session_state.get('show_all_hidden_changed', False):
123
+ st.session_state.show_all_hidden_changed = False
124
+ logger.info("犬のボタン状態変更フラグをクリアしました")
125
+
126
+ if st.session_state.get('force_chat_rerender', False):
127
+ st.session_state.force_chat_rerender = False
128
+ logger.info("強制チャット再レンダリングフラグをクリアしました")
129
+
130
+ except Exception as e:
131
+ logger.error(f"チャット履歴表示エラー: {e}")
132
+ st.error("チャット履歴の表示中にエラーが発生しました。")
133
+
134
+ def _render_custom_chat_bubble(self, role: str, content: str, is_initial: bool, message_id: str, timestamp: str = None):
135
+ """独自のチャットバブル表示(st.chat_messageを使わない安定版)"""
136
+ logger.info(f"🎨 カスタムチャットバブル開始: {role} - '{content[:30]}...' - 初期:{is_initial}")
137
+ try:
138
+ # チャットバブルのCSS
139
+ bubble_css = """
140
+ <style>
141
+ .custom-chat-container {
142
+ margin: 10px 0;
143
+ display: flex;
144
+ flex-direction: column;
145
+ }
146
+
147
+ .custom-chat-bubble {
148
+ max-width: 80%;
149
+ padding: 12px 16px;
150
+ border-radius: 18px;
151
+ margin: 4px 0;
152
+ word-wrap: break-word;
153
+ line-height: 1.5;
154
+ font-size: 18px;
155
+ }
156
+
157
+ .user-bubble {
158
+ background: #007bff;
159
+ color: white;
160
+ align-self: flex-end;
161
+ margin-left: auto;
162
+ }
163
+
164
+ .assistant-bubble {
165
+ background: #f1f3f4;
166
+ color: #333;
167
+ align-self: flex-start;
168
+ margin-right: auto;
169
+ border: 1px solid #e0e0e0;
170
+ }
171
+
172
+ .initial-message-bubble {
173
+ background: #e8f5e8 !important;
174
+ color: #2d5a2d !important;
175
+ font-weight: 500 !important;
176
+ border: 2px solid #4caf50 !important;
177
+ }
178
+
179
+ .chat-avatar {
180
+ width: 32px;
181
+ height: 32px;
182
+ border-radius: 50%;
183
+ margin: 0 8px;
184
+ display: flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ font-size: 16px;
188
+ flex-shrink: 0;
189
+ }
190
+
191
+ .user-avatar {
192
+ background: #007bff;
193
+ color: white;
194
+ }
195
+
196
+ .assistant-avatar {
197
+ background: #ff69b4;
198
+ color: white;
199
+ }
200
+
201
+ .chat-row {
202
+ display: flex;
203
+ align-items: flex-start;
204
+ margin: 8px 0;
205
+ }
206
+
207
+ .user-row {
208
+ flex-direction: row-reverse;
209
+ }
210
+
211
+ .assistant-row {
212
+ flex-direction: row;
213
+ }
214
+
215
+ .timestamp {
216
+ font-size: 0.8em;
217
+ color: #666;
218
+ margin-top: 4px;
219
+ text-align: center;
220
+ }
221
+ </style>
222
+ """
223
+
224
+ st.markdown(bubble_css, unsafe_allow_html=True)
225
+
226
+ # アバターとバブルのスタイル決定
227
+ if role == "user":
228
+ avatar_class = "user-avatar"
229
+ bubble_class = "user-bubble"
230
+ row_class = "user-row"
231
+ avatar_icon = "👤"
232
+ else:
233
+ avatar_class = "assistant-avatar"
234
+ bubble_class = "assistant-bubble"
235
+ row_class = "assistant-row"
236
+ avatar_icon = "🤖"
237
+
238
+ # 初期メッセージの場合は特別なスタイル
239
+ if is_initial:
240
+ bubble_class += " initial-message-bubble"
241
+ avatar_icon = "💬"
242
+
243
+ # コンテンツのHTMLエスケープ処理(HTMLタグとStreamlitクラス名を完全に除去)
244
+ import html
245
+ import re
246
+
247
+ # HTMLタグとStreamlitの内部クラス名を完全に除去
248
+ # 1. HTMLタグを除去(開始・終了タグ両方)
249
+ clean_content = re.sub(r'<[^>]*>', '', content)
250
+
251
+ # 2. Streamlitの内部クラス名を除去(より包括的)
252
+ clean_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_content)
253
+ clean_content = re.sub(r'class="[^"]*st-emotion-cache[^"]*"', '', clean_content)
254
+ clean_content = re.sub(r'class="[^"]*st-[^"]*"', '', clean_content)
255
+ clean_content = re.sub(r"class='[^']*st-emotion-cache[^']*'", '', clean_content)
256
+ clean_content = re.sub(r"class='[^']*st-[^']*'", '', clean_content)
257
+
258
+ # 3. HTML属性を除去
259
+ clean_content = re.sub(r'data-testid="[^"]*"', '', clean_content)
260
+ clean_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_content)
261
+ clean_content = re.sub(r'class="[^"]*"', '', clean_content)
262
+ clean_content = re.sub(r"class='[^']*'", '', clean_content)
263
+ clean_content = re.sub(r'id="[^"]*"', '', clean_content)
264
+ clean_content = re.sub(r"id='[^']*'", '', clean_content)
265
+
266
+ # 4. その他のHTML関連文字列を除去
267
+ clean_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_content) # HTMLエンティティ
268
+ clean_content = re.sub(r'[<>]', '', clean_content) # 残った角括弧
269
+
270
+ # 5. 余分な空白を除去
271
+ clean_content = re.sub(r'\s+', ' ', clean_content).strip()
272
+
273
+ # 6. HTMLエスケープ
274
+ escaped_content = html.escape(clean_content)
275
+
276
+ if content != clean_content:
277
+ logger.error(f"🚨 HTMLタグ混入検出! 元の内容: '{content}'")
278
+ logger.error(f"🚨 クリーン後: '{clean_content}'")
279
+ # スタックトレースを出力して呼び出し元を特定
280
+ import traceback
281
+ logger.error(f"🚨 呼び出しスタック: {traceback.format_stack()}")
282
+ else:
283
+ logger.debug(f"通常テキストをHTMLエスケープ: '{content[:30]}...'")
284
+
285
+ # チャットバブルのHTML生成
286
+ chat_html = f"""
287
+ <div class="custom-chat-container">
288
+ <div class="chat-row {row_class}">
289
+ <div class="chat-avatar {avatar_class}">
290
+ {avatar_icon}
291
+ </div>
292
+ <div class="custom-chat-bubble {bubble_class}">
293
+ {escaped_content}
294
+ {f'<div class="timestamp">{html.escape(timestamp)}</div>' if timestamp and st.session_state.get("debug_mode", False) else ''}
295
+ </div>
296
+ </div>
297
+ </div>
298
+ """
299
+
300
+ st.markdown(chat_html, unsafe_allow_html=True)
301
+
302
+ # 麻理のメッセージで隠された真実がある場合の処理
303
+ if role == "assistant" and not is_initial:
304
+ has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content)
305
+ if has_hidden_content:
306
+ # 犬のボタンの状態に応じて表示を切り替え
307
+ show_all_hidden = st.session_state.get('show_all_hidden', False)
308
+ logger.debug(f"隠された真実の表示判定: show_all_hidden={show_all_hidden}, has_hidden={has_hidden_content}")
309
+
310
+ if show_all_hidden:
311
+ # 本音表示モードの場合は隠された内容を表示
312
+ # HTMLタグとStreamlitクラス名を除去してからエスケープ
313
+ clean_hidden_content = re.sub(r'<[^>]*>', '', hidden_content)
314
+ clean_hidden_content = re.sub(r'st-emotion-cache-[a-zA-Z0-9]+', '', clean_hidden_content)
315
+ clean_hidden_content = re.sub(r'class="[^"]*"', '', clean_hidden_content)
316
+ clean_hidden_content = re.sub(r"class='[^']*'", '', clean_hidden_content)
317
+ clean_hidden_content = re.sub(r'data-[^=]*="[^"]*"', '', clean_hidden_content)
318
+ clean_hidden_content = re.sub(r'&[a-zA-Z0-9#]+;', '', clean_hidden_content)
319
+ clean_hidden_content = re.sub(r'[<>]', '', clean_hidden_content)
320
+ clean_hidden_content = re.sub(r'\s+', ' ', clean_hidden_content).strip()
321
+ escaped_hidden_content = html.escape(clean_hidden_content)
322
+
323
+ hidden_html = f"""
324
+ <div class="custom-chat-container">
325
+ <div class="chat-row assistant-row">
326
+ <div class="chat-avatar assistant-avatar">
327
+ 🐕
328
+ </div>
329
+ <div class="custom-chat-bubble assistant-bubble" style="background: #fff8e1 !important; border: 2px solid #ffc107 !important;">
330
+ <strong>🐕 ポチの本音翻訳:</strong><br>
331
+ {escaped_hidden_content}
332
+ </div>
333
+ </div>
334
+ </div>
335
+ """
336
+ st.markdown(hidden_html, unsafe_allow_html=True)
337
+ logger.debug(f"隠された真実を表示: '{clean_hidden_content[:30]}...'")
338
+ else:
339
+ logger.debug("通常モードのため隠された真実は非表示")
340
+
341
+ logger.debug(f"カスタムチャットバブル表示完了: {role} - {message_id}")
342
+
343
+ except Exception as e:
344
+ logger.error(f"カスタムチャットバブル表示エラー: {e}")
345
+ logger.error(f"エラー詳細: role={role}, content_len={len(content)}, is_initial={is_initial}, message_id={message_id}")
346
+ import traceback
347
+ logger.error(f"スタックトレース: {traceback.format_exc()}")
348
+ # フォールバック: シンプルなテキスト表示
349
+ st.markdown(f"**{role}**: {content}")
350
+ logger.info("フォールバック表示を実行しました")
351
+
352
+ def _render_mari_message_with_mask(self, message_id: str, content: str, is_initial: bool = False) -> None:
353
+ """
354
+ 麻理のメッセージをマスク機能付きで表示する(廃止予定)
355
+
356
+ Args:
357
+ message_id: メッセージの一意ID
358
+ content: メッセージ内容
359
+ is_initial: 初期メッセージかどうか
360
+ """
361
+ logger.warning("⚠️ 廃止予定のメソッドが呼ばれました: _render_mari_message_with_mask")
362
+ # カスタムチャットバブルに移行
363
+ self._render_custom_chat_bubble("assistant", content, is_initial, message_id)
364
+ return
365
+
366
+ try:
367
+ # メッセージ処理キャッシュをチェック(重複処理防止)
368
+ cache_key = f"processed_{message_id}_{hash(content)}"
369
+ if cache_key in st.session_state:
370
+ # キャッシュから結果を取得
371
+ cached_result = st.session_state[cache_key]
372
+ has_hidden_content = cached_result['has_hidden']
373
+ visible_content = cached_result['visible_content']
374
+ hidden_content = cached_result['hidden_content']
375
+ logger.debug(f"キャッシュからメッセージ処理結果を取得: {message_id}")
376
+ else:
377
+ # 隠された真実を検出
378
+ has_hidden_content, visible_content, hidden_content = self._detect_hidden_content(content)
379
+
380
+ # 結果をキャッシュに保存
381
+ st.session_state[cache_key] = {
382
+ 'has_hidden': has_hidden_content,
383
+ 'visible_content': visible_content,
384
+ 'hidden_content': hidden_content
385
+ }
386
+ logger.debug(f"メッセージ処理結果をキャッシュに保存: {message_id}")
387
+
388
+ # 隠された真実が検出されない場合のフォールバック処理
389
+ if not has_hidden_content:
390
+ logger.warning(f"隠された真実が検出されませんでした: '{content[:50]}...'")
391
+ # AIが[HIDDEN:...]形式で応答していない場合は通常表示
392
+
393
+ # セッション状態でフリップ状態を管理
394
+ if 'message_flip_states' not in st.session_state:
395
+ st.session_state.message_flip_states = {}
396
+
397
+ is_flipped = st.session_state.message_flip_states.get(message_id, False)
398
+
399
+ if has_hidden_content:
400
+ # マスクアイコン付きメッセージを表示
401
+ self._render_message_with_flip_animation(
402
+ message_id, visible_content, hidden_content, is_flipped, is_initial
403
+ )
404
+ else:
405
+ # 通常のメッセージ表示
406
+ if is_initial:
407
+ # 初期メッセージは確実に黒文字で表示(強制スタイル適用)
408
+ initial_message_html = f'''
409
+ <div class="mari-initial-message" style="
410
+ color: #333333 !important;
411
+ font-weight: 500 !important;
412
+ background: #f5f5f5 !important;
413
+ padding: 15px !important;
414
+ border-radius: 12px !important;
415
+ border: 1px solid rgba(0,0,0,0.1) !important;
416
+ margin: 8px 0 !important;
417
+ font-family: var(--mari-font) !important;
418
+ line-height: 1.7 !important;
419
+ ">
420
+ {content}
421
+ </div>
422
+ '''
423
+ st.markdown(initial_message_html, unsafe_allow_html=True)
424
+ logger.debug(f"初期メッセージを表示: '{content}'")
425
+ else:
426
+ st.markdown(content)
427
+
428
+ except Exception as e:
429
+ logger.error(f"マスク付きメッセージ表示エラー: {e}")
430
+ # フォールバック: 通常のメッセージ表示
431
+ st.markdown(content)
432
+
433
+ def _detect_hidden_content(self, content: str) -> Tuple[bool, str, str]:
434
+ """
435
+ メッセージから隠された真実を検出する
436
+
437
+ Args:
438
+ content: メッセージ内容
439
+
440
+ Returns:
441
+ (隠された内容があるか, 表示用内容, 隠された内容)
442
+ """
443
+ try:
444
+ # デバッグ用ログ(重複実行防止)
445
+ logger.debug(f"🔍 隠された内容検出中: '{content[:50]}...'")
446
+
447
+ # 隠された真実のマーカーを検索
448
+ # 形式: [HIDDEN:隠された内容]表示される内容
449
+ hidden_pattern = r'\[HIDDEN:(.*?)\](.*)'
450
+ match = re.search(hidden_pattern, content)
451
+
452
+ if match:
453
+ hidden_content = match.group(1).strip()
454
+ visible_content = match.group(2).strip()
455
+
456
+ # 複数HIDDENをチェック
457
+ additional_hidden = re.findall(r'\[HIDDEN:(.*?)\]', visible_content)
458
+ if additional_hidden:
459
+ logger.warning(f"⚠️ 複数HIDDEN検出: {len(additional_hidden) + 1}個のHIDDENが見つかりました")
460
+ # 2番目以降のHIDDENを表示内容から除去
461
+ visible_content = re.sub(r'\[HIDDEN:.*?\]', '', visible_content).strip()
462
+ logger.info(f"🔧 複数HIDDEN除去後: 表示='{visible_content}'")
463
+
464
+ logger.info(f"🐕 隠された真実を検出: 表示='{visible_content}', 隠し='{hidden_content}'")
465
+ return True, visible_content, hidden_content
466
+
467
+ # マーカーがない場合は通常のメッセージ
468
+ logger.debug(f"📝 通常メッセージ: '{content[:30]}...'")
469
+ return False, content, ""
470
+
471
+ except Exception as e:
472
+ logger.error(f"隠された内容検出エラー: {e}")
473
+ return False, content, ""
474
+
475
+ def _render_message_with_flip_animation(self, message_id: str, visible_content: str,
476
+ hidden_content: str, is_flipped: bool, is_initial: bool = False) -> None:
477
+ """
478
+ フリップアニメーション付きメッセージを表示する
479
+
480
+ Args:
481
+ message_id: メッセージID
482
+ visible_content: 表示用内容
483
+ hidden_content: 隠された内容
484
+ is_flipped: 現在フリップされているか
485
+ is_initial: 初期メッセージかどうか
486
+ """
487
+ try:
488
+ logger.info(f"🐕 ポチモード付きメッセージを表示: ID={message_id}, フリップ={is_flipped}")
489
+ # フリップアニメーション用CSS
490
+ flip_css = f"""
491
+ <style>
492
+ .message-container-{message_id} {{
493
+ position: relative;
494
+ perspective: 1000px;
495
+ margin: 10px 0;
496
+ }}
497
+
498
+ .message-flip-{message_id} {{
499
+ position: relative;
500
+ width: 100%;
501
+ height: auto;
502
+ min-height: 60px;
503
+ transform-style: preserve-3d;
504
+ transition: transform 0.4s ease-in-out;
505
+ transform: {'rotateY(180deg)' if is_flipped else 'rotateY(0deg)'};
506
+ }}
507
+
508
+ .message-side-{message_id} {{
509
+ position: absolute;
510
+ width: 100%;
511
+ backface-visibility: hidden;
512
+ padding: 15px 45px 15px 15px;
513
+ border-radius: 12px;
514
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
515
+ font-family: var(--mari-font);
516
+ line-height: 1.7;
517
+ min-height: 50px;
518
+ }}
519
+
520
+ .message-front-{message_id} {{
521
+ background: var(--mari-bubble-bg);
522
+ border: 1px solid rgba(0, 0, 0, 0.1);
523
+ color: var(--text-color);
524
+ transform: rotateY(0deg);
525
+ }}
526
+
527
+ .message-back-{message_id} {{
528
+ background: var(--hidden-bubble-bg);
529
+ border: 1px solid rgba(255, 248, 225, 0.7);
530
+ color: var(--text-color);
531
+ transform: rotateY(180deg);
532
+ box-shadow: 0 2px 8px rgba(255, 248, 225, 0.3);
533
+ }}
534
+
535
+ .mask-icon-{message_id} {{
536
+ position: absolute;
537
+ bottom: 12px;
538
+ right: 12px;
539
+ font-size: 20px;
540
+ cursor: pointer;
541
+ padding: 6px;
542
+ border-radius: 50%;
543
+ background: rgba(255, 255, 255, 0.9);
544
+ transition: all 0.3s ease;
545
+ z-index: 10;
546
+ user-select: none;
547
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
548
+ }}
549
+
550
+ .mask-icon-{message_id}:hover {{
551
+ background: rgba(255, 255, 255, 1);
552
+ transform: scale(1.1);
553
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
554
+ }}
555
+
556
+ .mask-icon-{message_id}:active {{
557
+ transform: scale(0.95);
558
+ }}
559
+
560
+ .tutorial-pulse-{message_id} {{
561
+ animation: tutorialPulse 2s ease-in-out infinite;
562
+ }}
563
+
564
+ @keyframes tutorialPulse {{
565
+ 0%, 100% {{
566
+ transform: scale(1);
567
+ box-shadow: 0 0 0 0 rgba(255, 105, 180, 0.7);
568
+ }}
569
+ 50% {{
570
+ transform: scale(1.1);
571
+ box-shadow: 0 0 0 10px rgba(255, 105, 180, 0);
572
+ }}
573
+ }}
574
+ </style>
575
+ """
576
+
577
+ # 犬のボタンの状態を事前にチェックして即座に反映(無限ループ防止)
578
+ show_all_hidden = st.session_state.get('show_all_hidden', False)
579
+
580
+ # 犬のボタンの状態に従って表示を切り替え(状態変更時のみ)
581
+ current_flip_state = st.session_state.message_flip_states.get(message_id, False)
582
+ if show_all_hidden != current_flip_state:
583
+ st.session_state.message_flip_states[message_id] = show_all_hidden
584
+ is_flipped = show_all_hidden
585
+ logger.debug(f"メッセージ {message_id} のフリップ状態を更新: {is_flipped}")
586
+ else:
587
+ is_flipped = current_flip_state
588
+
589
+ # 現在表示するコンテンツを決定
590
+ current_content = hidden_content if is_flipped else visible_content
591
+
592
+ # 初期メッセージの場合は確実に黒文字で表示
593
+ if is_initial:
594
+ initial_style = "color: #333333 !important; font-weight: 500 !important;"
595
+ initial_class = "mari-initial-message"
596
+ # 初期メッセージは背景色を固定
597
+ bg_color = "#F5F5F5"
598
+ logger.debug(f"初期メッセージをフリップ表示: '{current_content}'")
599
+ else:
600
+ initial_style = ""
601
+ initial_class = ""
602
+ # 通常メッセージは背景色を動的に設定
603
+ bg_color = "#FFF8E1" if is_flipped else "#F5F5F5"
604
+
605
+ # メッセージを全幅で表示(ボタンは削除)
606
+ message_style = f"""
607
+ <div style="
608
+ padding: 15px;
609
+ background: {bg_color};
610
+ border-radius: 12px;
611
+ border: 1px solid rgba(0,0,0,0.1);
612
+ min-height: 50px;
613
+ font-family: var(--mari-font);
614
+ line-height: 1.7;
615
+ margin: 8px 0;
616
+ ">
617
+ <div class="{initial_class}" style="{initial_style}">{current_content}</div>
618
+ </div>
619
+ """
620
+ st.markdown(message_style, unsafe_allow_html=True)
621
+
622
+ # 本音表示機能の状態表示(開発用)
623
+ if st.session_state.get("debug_mode", False):
624
+ st.caption(f"🐕 Dog Mode: ID={message_id}, Hidden={len(hidden_content)>0}, Showing={is_flipped}")
625
+
626
+ except Exception as e:
627
+ logger.error(f"フリップアニメーション表示エラー: {e}")
628
+ # フォールバック: 通常のメッセージ表示
629
+ st.markdown(visible_content)
630
+
631
+ def _is_tutorial_message(self, message_id: str) -> bool:
632
+ """
633
+ チュートリアル用のメッセージかどうかを判定する
634
+
635
+ Args:
636
+ message_id: メッセージID
637
+
638
+ Returns:
639
+ チュートリアルメッセージかどうか
640
+ """
641
+ # 初回のマスク付きメッセージの場合はチュートリアル扱い
642
+ tutorial_completed = st.session_state.get('mask_tutorial_completed', False)
643
+ return not tutorial_completed and message_id == "msg_0"
644
+
645
+ def validate_input(self, message: str) -> Tuple[bool, str]:
646
+ """
647
+ 入力メッセージの検証
648
+
649
+ Args:
650
+ message: 入力メッセージ
651
+
652
+ Returns:
653
+ (検証結果, エラーメッセージ)
654
+ """
655
+ if not message or not message.strip():
656
+ return False, "メッセージが空です。"
657
+
658
+ if len(message) > self.max_input_length:
659
+ return False, f"メッセージが長すぎます。{self.max_input_length}文字以内で入力してください。"
660
+
661
+ # 不正な文字のチェック
662
+ if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in message):
663
+ return False, "不正な文字が含まれています。"
664
+
665
+ return True, ""
666
+
667
+ def sanitize_message(self, message: str) -> str:
668
+ """
669
+ メッセージをサニタイズする
670
+
671
+ Args:
672
+ message: 入力メッセージ
673
+
674
+ Returns:
675
+ サニタイズされたメッセージ
676
+ """
677
+ try:
678
+ # 基本的なサニタイズ
679
+ sanitized = message.strip()
680
+
681
+ # HTMLエスケープ(Streamlitが自動で行うが念のため)
682
+ sanitized = sanitized.replace("<", "&lt;").replace(">", "&gt;")
683
+
684
+ # 連続する空白を単一の空白に変換
685
+ import re
686
+ sanitized = re.sub(r'\s+', ' ', sanitized)
687
+
688
+ return sanitized
689
+
690
+ except Exception as e:
691
+ logger.error(f"メッセージサニタイズエラー: {e}")
692
+ return message
693
+
694
+ def add_message(self, role: str, content: str,
695
+ messages: Optional[List[Dict[str, str]]] = None,
696
+ message_id: Optional[str] = None) -> List[Dict[str, str]]:
697
+ """
698
+ メッセージをリストに追加する(マスク機能対応)
699
+
700
+ Args:
701
+ role: メッセージの役割 ('user' or 'assistant')
702
+ content: メッセージ内容
703
+ messages: メッセージリスト(Noneの場合はsession_stateから取得)
704
+ message_id: メッセージの一意ID(Noneの場合は自動生成)
705
+
706
+ Returns:
707
+ 更新されたメッセージリスト
708
+ """
709
+ try:
710
+ if messages is None:
711
+ messages = st.session_state.get('messages', [])
712
+
713
+ # メッセージIDを生成または使用
714
+ if message_id is None:
715
+ message_id = f"msg_{len(messages)}_{uuid.uuid4().hex[:8]}"
716
+
717
+ # メッセージオブジェクトを作成
718
+ message = {
719
+ "role": role,
720
+ "content": self.sanitize_message(content),
721
+ "timestamp": datetime.now().isoformat(),
722
+ "message_id": message_id
723
+ }
724
+
725
+ messages.append(message)
726
+
727
+ # セッション状態を更新
728
+ st.session_state.messages = messages
729
+
730
+ logger.info(f"メッセージを追加: {role} - {len(content)}文字 (ID: {message_id})")
731
+ return messages
732
+
733
+ except Exception as e:
734
+ logger.error(f"メッセージ追加エラー: {e}")
735
+ return messages or []
736
+
737
+ def create_hidden_content_message(self, visible_content: str, hidden_content: str) -> str:
738
+ """
739
+ 隠された真実を含むメッセージを作成する
740
+
741
+ Args:
742
+ visible_content: 表示される内容
743
+ hidden_content: 隠された内容
744
+
745
+ Returns:
746
+ マーカー付きメッセージ
747
+ """
748
+ return f"[HIDDEN:{hidden_content}]{visible_content}"
749
+
750
+ def generate_mock_hidden_content(self, visible_content: str) -> str:
751
+ """
752
+ テスト用のモック隠された内容を生成する
753
+
754
+ Args:
755
+ visible_content: 表示される内容
756
+
757
+ Returns:
758
+ 隠された内容
759
+ """
760
+ # 簡単なモック生成ロジック
761
+ mock_patterns = {
762
+ "何の用?": "(本当は嬉しいけど...素直になれない)",
763
+ "別に": "(実はすごく気になってる)",
764
+ "そうね": "(もっと話していたい)",
765
+ "まあまあ": "(とても楽しい!)",
766
+ "普通": "(特別な時間だと思ってる)",
767
+ "いいんじゃない": "(すごく良いと思う!)",
768
+ "そんなことない": "(本当はそう思ってる)"
769
+ }
770
+
771
+ for pattern, hidden in mock_patterns.items():
772
+ if pattern in visible_content:
773
+ return hidden
774
+
775
+ # デフォルトの隠された内容
776
+ return "(本当の気持ちは...)"
777
+
778
+ def render_input_area(self, placeholder: str = "メッセージを入力してください...") -> Optional[str]:
779
+ """
780
+ 入力エリアをレンダリングし、入力を取得する
781
+
782
+ Args:
783
+ placeholder: 入力フィールドのプレースホルダー
784
+
785
+ Returns:
786
+ 入力されたメッセージ(入力がない場合はNone)
787
+ """
788
+ try:
789
+ # レート制限チェック
790
+ if st.session_state.get('limiter_state', {}).get('is_blocked', False):
791
+ st.warning("⏰ レート制限中です。しばらくお待ちください。")
792
+ st.chat_input(placeholder, disabled=True)
793
+ return None
794
+
795
+ # 通常の入力フィールド
796
+ user_input = st.chat_input(placeholder)
797
+
798
+ if user_input:
799
+ # 入力検証
800
+ is_valid, error_msg = self.validate_input(user_input)
801
+ if not is_valid:
802
+ st.error(error_msg)
803
+ return None
804
+
805
+ return user_input
806
+
807
+ return None
808
+
809
+ except Exception as e:
810
+ logger.error(f"入力エリア表示エラー: {e}")
811
+ st.error("入力エリアの表示中にエラーが発生しました。")
812
+ return None
813
+
814
+ def show_typing_indicator(self, message: str = "考え中...") -> None:
815
+ """
816
+ タイピングインジケーターを表示する
817
+
818
+ Args:
819
+ message: 表示するメッセージ
820
+ """
821
+ return st.spinner(message)
822
+
823
+ def clear_chat_history(self) -> None:
824
+ """チャット履歴をクリアする"""
825
+ try:
826
+ st.session_state.messages = []
827
+ logger.info("チャット履歴をクリアしました")
828
+
829
+ except Exception as e:
830
+ logger.error(f"チャット履歴クリアエラー: {e}")
831
+
832
+ def get_chat_stats(self) -> Dict[str, int]:
833
+ """
834
+ チャットの統計情報を取得する
835
+
836
+ Returns:
837
+ 統計情報の辞書
838
+ """
839
+ try:
840
+ messages = st.session_state.get('messages', [])
841
+
842
+ user_messages = [msg for msg in messages if msg.get("role") == "user"]
843
+ assistant_messages = [msg for msg in messages if msg.get("role") == "assistant"]
844
+
845
+ total_chars = sum(len(msg.get("content", "")) for msg in messages)
846
+
847
+ return {
848
+ "total_messages": len(messages),
849
+ "user_messages": len(user_messages),
850
+ "assistant_messages": len(assistant_messages),
851
+ "total_characters": total_chars,
852
+ "average_message_length": total_chars // len(messages) if messages else 0
853
+ }
854
+
855
+ except Exception as e:
856
+ logger.error(f"統計情報取得エラー: {e}")
857
+ return {
858
+ "total_messages": 0,
859
+ "user_messages": 0,
860
+ "assistant_messages": 0,
861
+ "total_characters": 0,
862
+ "average_message_length": 0
863
+ }
864
+
865
+ def export_chat_history(self) -> str:
866
+ """
867
+ チャット履歴をエクスポート用の文字列として取得する
868
+
869
+ Returns:
870
+ エクスポート用の文字列
871
+ """
872
+ try:
873
+ messages = st.session_state.get('messages', [])
874
+
875
+ if not messages:
876
+ return "チャット履歴がありません。"
877
+
878
+ export_lines = []
879
+ export_lines.append("=== 麻理チャット履歴 ===")
880
+ export_lines.append(f"エクスポート日時: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
881
+ export_lines.append("")
882
+
883
+ for i, message in enumerate(messages, 1):
884
+ role = "ユーザー" if message.get("role") == "user" else "麻理"
885
+ content = message.get("content", "")
886
+ timestamp = message.get("timestamp", "")
887
+
888
+ export_lines.append(f"[{i}] {role}: {content}")
889
+ if timestamp:
890
+ export_lines.append(f" 時刻: {timestamp}")
891
+ export_lines.append("")
892
+
893
+ return "\n".join(export_lines)
894
+
895
+ except Exception as e:
896
+ logger.error(f"履歴エクスポートエラー: {e}")
897
+ return "エクスポート中にエラーが発生しました。"
components_dog_assistant.py ADDED
@@ -0,0 +1,473 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ポチ(犬)アシスタントコンポーネント
3
+ 画面右下に固定配置され、本音表示機能を提供する
4
+ """
5
+ import streamlit as st
6
+ import logging
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class DogAssistant:
11
+ """ポチ(犬)アシスタントクラス"""
12
+
13
+ def __init__(self):
14
+ """初期化"""
15
+ self.default_message = "ポチは麻理の本音を察知したようだ・・・"
16
+ self.active_message = "ワンワン!本音が見えてるワン!"
17
+
18
+ def render_dog_component(self, tutorial_manager=None):
19
+ """画面右下に固定配置される犬のコンポーネントを描画"""
20
+ try:
21
+ # 犬のボタン表示前にチャットセッション状態を確認
22
+ if 'chat' not in st.session_state:
23
+ logger.warning("犬のコンポーネント表示前にチャットセッションが存在しません - 初期化します")
24
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
25
+ st.session_state.chat = {
26
+ "messages": [initial_message],
27
+ "affection": 30,
28
+ "scene_params": {"theme": "default"},
29
+ "limiter_state": {},
30
+ "scene_change_pending": None,
31
+ "ura_mode": False
32
+ }
33
+ logger.info("犬のコンポーネント表示前にチャットセッションを初期化しました")
34
+ elif 'messages' not in st.session_state.chat:
35
+ logger.warning("犬のコンポーネント表示前にメッセージリストが存在しません - 初期化します")
36
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
37
+ st.session_state.chat['messages'] = [initial_message]
38
+ logger.info("犬のコンポーネント表示前にメッセージリストを初期化しました")
39
+ elif not any(msg.get('is_initial', False) for msg in st.session_state.chat['messages']):
40
+ logger.warning("犬のコンポーネント表示前に初期メッセージが見つかりません - 復元します")
41
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
42
+ st.session_state.chat['messages'].insert(0, initial_message)
43
+ logger.info("犬のコンポーネント表示前に初期メッセージを復元しました")
44
+ # 犬のコンポーネントのCSS(レスポンシブ対応)
45
+ dog_css = """
46
+ <style>
47
+ .dog-assistant-container {
48
+ position: fixed;
49
+ bottom: 20px;
50
+ right: 20px;
51
+ z-index: 1000;
52
+ display: flex;
53
+ flex-direction: column;
54
+ align-items: flex-start;
55
+ pointer-events: none;
56
+ transform: translateX(-50px);
57
+ }
58
+
59
+ .dog-speech-bubble {
60
+ background-color: rgba(255, 255, 255, 0.95);
61
+ color: #333;
62
+ padding: 10px 15px;
63
+ border-radius: 20px;
64
+ font-size: 13px;
65
+ margin-bottom: 10px;
66
+ position: relative;
67
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
68
+ backdrop-filter: blur(10px);
69
+ border: 1px solid rgba(0,0,0,0.1);
70
+ max-width: 200px;
71
+ word-wrap: break-word;
72
+ animation: bubbleFloat 3s ease-in-out infinite;
73
+ pointer-events: auto;
74
+ }
75
+
76
+ .dog-speech-bubble::after {
77
+ content: '';
78
+ position: absolute;
79
+ bottom: -8px;
80
+ left: 20px;
81
+ width: 0;
82
+ height: 0;
83
+ border-left: 8px solid transparent;
84
+ border-right: 8px solid transparent;
85
+ border-top: 8px solid rgba(255, 255, 255, 0.95);
86
+ }
87
+
88
+ .dog-button {
89
+ background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 50%, #fecfef 100%);
90
+ border: none;
91
+ border-radius: 50%;
92
+ width: 70px;
93
+ height: 70px;
94
+ cursor: pointer;
95
+ transition: all 0.3s ease;
96
+ box-shadow: 0 4px 15px rgba(255, 154, 158, 0.4);
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ font-size: 35px;
101
+ pointer-events: auto;
102
+ animation: dogBounce 2s ease-in-out infinite;
103
+ }
104
+
105
+ .dog-button:hover {
106
+ transform: scale(1.1);
107
+ box-shadow: 0 6px 20px rgba(255, 154, 158, 0.6);
108
+ background: linear-gradient(135deg, #ff6b6b 0%, #feca57 50%, #ff9ff3 100%);
109
+ }
110
+
111
+ .dog-button:active {
112
+ transform: scale(0.95);
113
+ }
114
+
115
+ .dog-button.active {
116
+ background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
117
+ animation: dogActive 1s ease-in-out infinite;
118
+ }
119
+
120
+ @keyframes bubbleFloat {
121
+ 0%, 100% { transform: translateY(0px); }
122
+ 50% { transform: translateY(-5px); }
123
+ }
124
+
125
+ @keyframes dogBounce {
126
+ 0%, 100% { transform: translateY(0px); }
127
+ 50% { transform: translateY(-3px); }
128
+ }
129
+
130
+ @keyframes dogActive {
131
+ 0%, 100% { transform: scale(1) rotate(0deg); }
132
+ 25% { transform: scale(1.05) rotate(-2deg); }
133
+ 75% { transform: scale(1.05) rotate(2deg); }
134
+ }
135
+
136
+ /* レスポンシブ対応 */
137
+ @media (max-width: 768px) {
138
+ .dog-assistant-container {
139
+ bottom: 15px;
140
+ right: 15px;
141
+ transform: translateX(-40px);
142
+ }
143
+
144
+ .dog-speech-bubble {
145
+ max-width: 150px;
146
+ font-size: 12px;
147
+ padding: 8px 12px;
148
+ }
149
+
150
+ .dog-button {
151
+ width: 60px;
152
+ height: 60px;
153
+ font-size: 30px;
154
+ }
155
+ }
156
+
157
+ @media (max-width: 480px) {
158
+ .dog-assistant-container {
159
+ bottom: 10px;
160
+ right: 10px;
161
+ transform: translateX(-30px);
162
+ }
163
+
164
+ .dog-speech-bubble {
165
+ max-width: 120px;
166
+ font-size: 11px;
167
+ padding: 6px 10px;
168
+ }
169
+
170
+ .dog-button {
171
+ width: 50px;
172
+ height: 50px;
173
+ font-size: 25px;
174
+ }
175
+ }
176
+
177
+ /* 画面が非常に小さい場合は吹き出しを非表示 */
178
+ @media (max-width: 320px) {
179
+ .dog-speech-bubble {
180
+ display: none;
181
+ }
182
+ }
183
+ </style>
184
+ """
185
+
186
+ # 現在の状態を取得
187
+ is_active = st.session_state.get('show_all_hidden', False)
188
+ bubble_text = self.active_message if is_active else self.default_message
189
+ button_class = "dog-button active" if is_active else "dog-button"
190
+
191
+ # JavaScriptでクリックイベントを処理
192
+ dog_js = f"""
193
+ <script>
194
+ function toggleDogMode() {{
195
+ // Streamlitのセッション状態を更新するためのトリガー
196
+ const event = new CustomEvent('dogButtonClick', {{
197
+ detail: {{ active: {str(is_active).lower()} }}
198
+ }});
199
+ window.dispatchEvent(event);
200
+
201
+ // ボタンの状態を即座に更新
202
+ const button = document.querySelector('.dog-button');
203
+ const bubble = document.querySelector('.dog-speech-bubble');
204
+
205
+ if (button && bubble) {{
206
+ if ({str(is_active).lower()}) {{
207
+ button.classList.remove('active');
208
+ bubble.textContent = '{self.default_message}';
209
+ }} else {{
210
+ button.classList.add('active');
211
+ bubble.textContent = '{self.active_message}';
212
+ }}
213
+ }}
214
+ }}
215
+
216
+ // ページ読み込み時にイベントリスナーを設定
217
+ document.addEventListener('DOMContentLoaded', function() {{
218
+ const button = document.querySelector('.dog-button');
219
+ if (button) {{
220
+ button.addEventListener('click', toggleDogMode);
221
+ }}
222
+ }});
223
+
224
+ // Streamlitの再描画後にもイベントリスナーを再設定
225
+ setTimeout(function() {{
226
+ const button = document.querySelector('.dog-button');
227
+ if (button) {{
228
+ button.addEventListener('click', toggleDogMode);
229
+ }}
230
+ }}, 100);
231
+ </script>
232
+ """
233
+
234
+ # HTMLコンポーネント
235
+ dog_html = f"""
236
+ <div class="dog-assistant-container">
237
+ <div class="dog-speech-bubble">
238
+ {bubble_text}
239
+ </div>
240
+ <button class="{button_class}" title="ポチが麻理の本音を察知します" onclick="toggleDogMode()">
241
+ 🐕
242
+ </button>
243
+ </div>
244
+ """
245
+
246
+ # HTMLコンポーネント(ボタン以外)を表示
247
+ dog_display_html = f"""
248
+ <div class="dog-assistant-container">
249
+ <div class="dog-speech-bubble">
250
+ {bubble_text}
251
+ </div>
252
+ <div style="width: 70px; height: 70px; display: flex; align-items: center; justify-content: center;">
253
+ <!-- Streamlitボタンがここに配置される -->
254
+ </div>
255
+ </div>
256
+ """
257
+
258
+ st.markdown(dog_css + dog_display_html, unsafe_allow_html=True)
259
+
260
+ # Streamlitボタンを固定位置に配置
261
+ button_css = """
262
+ <style>
263
+ .dog-button-overlay {
264
+ position: fixed;
265
+ bottom: 20px;
266
+ right: 20px;
267
+ z-index: 1001;
268
+ pointer-events: auto;
269
+ }
270
+
271
+ .dog-button-overlay .stButton > button {
272
+ background: linear-gradient(135deg, #ff9a9e 0%, #fecfef 100%);
273
+ border: none;
274
+ border-radius: 50%;
275
+ width: 70px;
276
+ height: 70px;
277
+ font-size: 35px;
278
+ color: white;
279
+ box-shadow: 0 4px 15px rgba(255, 154, 158, 0.4);
280
+ transition: all 0.3s ease;
281
+ animation: dogBounce 2s ease-in-out infinite;
282
+ }
283
+
284
+ .dog-button-overlay .stButton > button:hover {
285
+ transform: scale(1.1);
286
+ box-shadow: 0 6px 20px rgba(255, 154, 158, 0.6);
287
+ }
288
+
289
+ @keyframes dogBounce {
290
+ 0%, 100% { transform: translateY(0px); }
291
+ 50% { transform: translateY(-3px); }
292
+ }
293
+
294
+ @media (max-width: 768px) {
295
+ .dog-button-overlay {
296
+ bottom: 15px;
297
+ right: 15px;
298
+ }
299
+
300
+ .dog-button-overlay .stButton > button {
301
+ width: 60px;
302
+ height: 60px;
303
+ font-size: 30px;
304
+ }
305
+ }
306
+
307
+ @media (max-width: 480px) {
308
+ .dog-button-overlay {
309
+ bottom: 10px;
310
+ right: 10px;
311
+ }
312
+
313
+ .dog-button-overlay .stButton > button {
314
+ width: 50px;
315
+ height: 50px;
316
+ font-size: 25px;
317
+ }
318
+ }
319
+ </style>
320
+ """
321
+
322
+ st.markdown(button_css, unsafe_allow_html=True)
323
+ st.markdown('<div class="dog-button-overlay">', unsafe_allow_html=True)
324
+
325
+ # ボタンクリック処理
326
+ button_key = f"dog_fixed_{is_active}"
327
+ button_help = "本音を隠す" if is_active else "本音を見る"
328
+ if st.button("🐕", key=button_key, help=button_help):
329
+ self.handle_dog_button_click(tutorial_manager)
330
+ logger.info("右下の犬のボタンがクリックされました")
331
+
332
+ st.markdown('</div>', unsafe_allow_html=True)
333
+
334
+ logger.debug(f"犬のコンポーネントを描画しました (active: {is_active})")
335
+
336
+ except Exception as e:
337
+ logger.error(f"犬のコンポーネント描画エラー: {e}")
338
+
339
+ def handle_dog_button_click(self, tutorial_manager=None):
340
+ """犬のボタンクリック処理(無限ループ防止版)"""
341
+ try:
342
+ # 本音表示機能のトリガー
343
+ if 'show_all_hidden' not in st.session_state:
344
+ st.session_state.show_all_hidden = False
345
+
346
+ # 現在の状態を取得
347
+ current_state = st.session_state.show_all_hidden
348
+ new_state = not current_state
349
+
350
+ # 状態を更新
351
+ st.session_state.show_all_hidden = new_state
352
+ # チャット履歴の強制再表示フラグを設定
353
+ st.session_state.show_all_hidden_changed = True
354
+ logger.info(f"犬のボタン状態変更: {current_state} -> {new_state}")
355
+
356
+ # 全メッセージのフリップ状態を即座に更新
357
+ if 'message_flip_states' not in st.session_state:
358
+ st.session_state.message_flip_states = {}
359
+
360
+ # 現在のメッセージに対してフリップ状態を設定
361
+ if 'chat' in st.session_state and 'messages' in st.session_state.chat:
362
+ # 初期メッセージが存在することを確認
363
+ messages = st.session_state.chat['messages']
364
+ if not any(msg.get('is_initial', False) for msg in messages):
365
+ logger.warning("犬のボタン押下時に初期メッセージが見つかりません - 復元します")
366
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
367
+ st.session_state.chat['messages'].insert(0, initial_message)
368
+ logger.info("犬のボタン押下時に初期メッセージを復元しました")
369
+
370
+ for i, message in enumerate(st.session_state.chat['messages']):
371
+ if message['role'] == 'assistant':
372
+ message_id = f"msg_{i}"
373
+ st.session_state.message_flip_states[message_id] = new_state
374
+ else:
375
+ logger.warning("犬のボタン押下時にチャットセッションが存在しません - 初期化します")
376
+ # チャットセッションが存在しない場合は初期化
377
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
378
+ if 'chat' not in st.session_state:
379
+ st.session_state.chat = {
380
+ "messages": [initial_message],
381
+ "affection": 30,
382
+ "scene_params": {"theme": "default"},
383
+ "limiter_state": {},
384
+ "scene_change_pending": None,
385
+ "ura_mode": False
386
+ }
387
+ else:
388
+ st.session_state.chat['messages'] = [initial_message]
389
+ logger.info("犬のボタン押下時にチャットセッションを初期化しました")
390
+
391
+ # チュートリアルステップ2を完了(tutorial_managerが渡された場合)
392
+ if tutorial_manager:
393
+ tutorial_manager.check_step_completion(2, True)
394
+
395
+ # 通知メッセージ(一度だけ表示)
396
+ if new_state:
397
+ st.success("🐕 ポチが麻理の本音を察知しました!")
398
+ else:
399
+ st.info("🐕 ポチが通常モードに戻りました。")
400
+
401
+ logger.info(f"犬のボタン状態変更完了: {current_state} → {new_state}")
402
+
403
+ # 状態変更を確実に反映するため、強制的に再実行
404
+ st.rerun()
405
+
406
+ except Exception as e:
407
+ logger.error(f"犬のボタンクリック処理エラー: {e}")
408
+
409
+ def render_with_streamlit_button(self):
410
+ """Streamlitのボタンを使用した代替実装(フォールバック用)"""
411
+ try:
412
+ # 固定位置のCSS
413
+ fallback_css = """
414
+ <style>
415
+ .dog-fallback-container {
416
+ position: fixed;
417
+ bottom: 20px;
418
+ right: 20px;
419
+ z-index: 1000;
420
+ background: rgba(255, 255, 255, 0.9);
421
+ border-radius: 15px;
422
+ padding: 10px;
423
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
424
+ backdrop-filter: blur(10px);
425
+ }
426
+
427
+ @media (max-width: 768px) {
428
+ .dog-fallback-container {
429
+ bottom: 15px;
430
+ right: 15px;
431
+ padding: 8px;
432
+ }
433
+ }
434
+ </style>
435
+ """
436
+
437
+ st.markdown(fallback_css, unsafe_allow_html=True)
438
+
439
+ # コンテナの開始
440
+ st.markdown('<div class="dog-fallback-container">', unsafe_allow_html=True)
441
+
442
+ # 状態表示
443
+ is_active = st.session_state.get('show_all_hidden', False)
444
+ status_text = "本音モード中" if is_active else "通常モード"
445
+ st.caption(f"🐕 {status_text}")
446
+
447
+ # ボタン
448
+ button_text = "🔄 戻す" if is_active else "🐕 本音を見る"
449
+ if st.button(button_text, key="dog_assistant_btn"):
450
+ # チュートリアルマネージャーを取得(可能な場合)
451
+ tutorial_manager = None
452
+ try:
453
+ # セッション状態からチュートリアルマネージャーを取得する試み
454
+ # (完全ではないが、フォールバック用)
455
+ pass
456
+ except:
457
+ pass
458
+
459
+ self.handle_dog_button_click(tutorial_manager)
460
+ # st.rerun()を削除 - 状態変更により自動的に再描画される
461
+
462
+ # コンテナの終了
463
+ st.markdown('</div>', unsafe_allow_html=True)
464
+
465
+ except Exception as e:
466
+ logger.error(f"犬のフォールバック描画エラー: {e}")
467
+
468
+ def get_current_state(self):
469
+ """現在の犬の状態を取得"""
470
+ return {
471
+ 'is_active': st.session_state.get('show_all_hidden', False),
472
+ 'message': self.active_message if st.session_state.get('show_all_hidden', False) else self.default_message
473
+ }
components_status_display.py ADDED
@@ -0,0 +1,640 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ステータス表示コンポーネント
3
+ 好感度ゲージと関係ステージの表示を担当する
4
+ """
5
+ import streamlit as st
6
+ import logging
7
+ from typing import Dict, Tuple
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class StatusDisplay:
12
+ """ステータス表示を管理するクラス"""
13
+
14
+ def __init__(self):
15
+ """ステータス表示の初期化"""
16
+ self.stage_colors = {
17
+ "敵対": {"color": "#ff4757", "emoji": "🔴", "bg_color": "rgba(255, 71, 87, 0.1)"},
18
+ "警戒": {"color": "#ff6348", "emoji": "🟠", "bg_color": "rgba(255, 99, 72, 0.1)"},
19
+ "中立": {"color": "#ffa502", "emoji": "🟡", "bg_color": "rgba(255, 165, 2, 0.1)"},
20
+ "好意": {"color": "#2ed573", "emoji": "🟢", "bg_color": "rgba(46, 213, 115, 0.1)"},
21
+ "親密": {"color": "#a55eea", "emoji": "💜", "bg_color": "rgba(165, 94, 234, 0.1)"}
22
+ }
23
+
24
+ def get_affection_color(self, affection: int) -> str:
25
+ """
26
+ 好感度に基づいて色を取得する
27
+
28
+ Args:
29
+ affection: 好感度値 (0-100)
30
+
31
+ Returns:
32
+ 色のHEXコード
33
+ """
34
+ if affection < 20:
35
+ return "#ff4757" # 赤
36
+ elif affection < 40:
37
+ return "#ff6348" # オレンジ
38
+ elif affection < 60:
39
+ return "#ffa502" # 黄色
40
+ elif affection < 80:
41
+ return "#2ed573" # 緑
42
+ else:
43
+ return "#a55eea" # 紫
44
+
45
+ def get_relationship_stage_info(self, affection: int) -> Dict[str, str]:
46
+ """
47
+ 好感度から関係性ステージの情報を取得する
48
+
49
+ Args:
50
+ affection: 好感度値 (0-100)
51
+
52
+ Returns:
53
+ ステージ情報の辞書
54
+ """
55
+ if affection < 20:
56
+ stage = "敵対"
57
+ elif affection < 40:
58
+ stage = "警戒"
59
+ elif affection < 60:
60
+ stage = "中立"
61
+ elif affection < 80:
62
+ stage = "好意"
63
+ else:
64
+ stage = "親密"
65
+
66
+ # 古いキー形式との互換性を保つため、新しいキーで検索し、見つからない場合は中立を返す
67
+ stage_info = None
68
+ for key, value in self.stage_colors.items():
69
+ if stage in key:
70
+ stage_info = value
71
+ break
72
+
73
+ return stage_info or self.stage_colors.get("中立", {"color": "#ffa502", "emoji": "🟡", "bg_color": "rgba(255, 165, 2, 0.1)"})
74
+
75
+ def render_affection_gauge(self, affection: int) -> None:
76
+ """
77
+ 好感度ゲージを表示する
78
+
79
+ Args:
80
+ affection: 好感度値 (0-100)
81
+ """
82
+ try:
83
+ # 好感度の値を0-100の範囲に制限
84
+ affection = max(0, min(100, affection))
85
+
86
+ # 好感度メトリック表示
87
+ col1, col2 = st.columns([2, 1])
88
+ with col1:
89
+ st.metric("好感度", f"{affection}/100")
90
+ with col2:
91
+ # 好感度の変化を表示(前回の値と比較)
92
+ prev_affection = st.session_state.get('prev_affection', affection)
93
+ delta = affection - prev_affection
94
+ if delta != 0:
95
+ st.metric("変化", f"{delta:+d}")
96
+ st.session_state.prev_affection = affection
97
+
98
+ # プログレスバー
99
+ progress_value = affection / 100.0
100
+ affection_color = self.get_affection_color(affection)
101
+
102
+ # カスタムプログレスバーのCSS
103
+ progress_css = f"""
104
+ <style>
105
+ .affection-progress {{
106
+ width: 100%;
107
+ height: 25px;
108
+ background-color: rgba(255, 255, 255, 0.1);
109
+ border-radius: 12px;
110
+ overflow: hidden;
111
+ margin: 10px 0;
112
+ border: 1px solid rgba(255, 255, 255, 0.2);
113
+ }}
114
+ .affection-fill {{
115
+ height: 100%;
116
+ background: linear-gradient(90deg,
117
+ #ff4757 0%,
118
+ #ff6348 25%,
119
+ #ffa502 50%,
120
+ #2ed573 75%,
121
+ #a55eea 100%);
122
+ width: {progress_value * 100}%;
123
+ transition: width 0.5s ease-in-out;
124
+ position: relative;
125
+ }}
126
+ .affection-text {{
127
+ position: absolute;
128
+ top: 50%;
129
+ left: 50%;
130
+ transform: translate(-50%, -50%);
131
+ color: white;
132
+ font-weight: bold;
133
+ text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
134
+ font-size: 12px;
135
+ }}
136
+ </style>
137
+ """
138
+
139
+ st.markdown(progress_css, unsafe_allow_html=True)
140
+
141
+ # プログレ��バーのHTML
142
+ progress_html = f"""
143
+ <div class="affection-progress">
144
+ <div class="affection-fill">
145
+ <div class="affection-text">{affection}%</div>
146
+ </div>
147
+ </div>
148
+ """
149
+
150
+ st.markdown(progress_html, unsafe_allow_html=True)
151
+
152
+ # Streamlitの標準プログレスバーも表示(フォールバック)
153
+ st.progress(progress_value)
154
+
155
+ except Exception as e:
156
+ logger.error(f"好感度ゲージ表示エラー: {e}")
157
+ # フォールバック表示
158
+ st.metric("好感度", f"{affection}/100")
159
+ st.progress(affection / 100.0)
160
+
161
+ def render_relationship_stage(self, affection: int) -> None:
162
+ """
163
+ 関係性ステージを表示する
164
+
165
+ Args:
166
+ affection: 好感度値 (0-100)
167
+ """
168
+ try:
169
+ stage_info = self.get_relationship_stage_info(affection)
170
+
171
+ # ステージ名を取得
172
+ if affection < 20:
173
+ stage_name = "ステージ1:敵対"
174
+ stage_description = "麻理はあなたを敵視している"
175
+ elif affection < 40:
176
+ stage_name = "ステージ2:警戒"
177
+ stage_description = "麻理はあなたを警戒している"
178
+ elif affection < 60:
179
+ stage_name = "ステージ3:中立"
180
+ stage_description = "麻理はあなたに対して中立的"
181
+ elif affection < 80:
182
+ stage_name = "ステージ4:好意"
183
+ stage_description = "麻理はあなたに好意を持っている"
184
+ else:
185
+ stage_name = "ステージ5:親密"
186
+ stage_description = "麻理はあなたと親密な関係"
187
+
188
+ # ステージ表示のCSS
189
+ stage_css = f"""
190
+ <style>
191
+ .relationship-stage {{
192
+ background: {stage_info['bg_color']};
193
+ border: 2px solid {stage_info['color']};
194
+ border-radius: 10px;
195
+ padding: 15px;
196
+ margin: 10px 0;
197
+ text-align: center;
198
+ }}
199
+ .stage-emoji {{
200
+ font-size: 24px;
201
+ margin-bottom: 5px;
202
+ }}
203
+ .stage-name {{
204
+ color: {stage_info['color']};
205
+ font-weight: bold;
206
+ font-size: 16px;
207
+ margin-bottom: 5px;
208
+ }}
209
+ .stage-description {{
210
+ color: {stage_info['color']};
211
+ font-size: 12px;
212
+ opacity: 0.8;
213
+ }}
214
+ </style>
215
+ """
216
+
217
+ st.markdown(stage_css, unsafe_allow_html=True)
218
+
219
+ # ステージ表示のHTML
220
+ stage_html = f"""
221
+ <div class="relationship-stage">
222
+ <div class="stage-emoji">{stage_info['emoji']}</div>
223
+ <div class="stage-name">{stage_name}</div>
224
+ <div class="stage-description">{stage_description}</div>
225
+ </div>
226
+ """
227
+
228
+ st.markdown(stage_html, unsafe_allow_html=True)
229
+
230
+ # フォールバック表示
231
+ st.write(f"{stage_info['emoji']} **関係性**: {stage_name}")
232
+
233
+ except Exception as e:
234
+ logger.error(f"関係性ステージ表示エラー: {e}")
235
+ # フォールバック表示
236
+ if affection < 20:
237
+ st.write("🔴 **関係性**: ステージ1:敵対")
238
+ elif affection < 40:
239
+ st.write("🟠 **関係性**: ステージ2:中立")
240
+ elif affection < 60:
241
+ st.write("🟡 **関係性**: ステージ3:好意")
242
+ elif affection < 80:
243
+ st.write("🟢 **関係性**: ステージ4:親密")
244
+ else:
245
+ st.write("💜 **関係性**: ステージ5:最接近")
246
+
247
+ def render_affection_history(self, max_history: int = 10) -> None:
248
+ """
249
+ 好感度の履歴を表示する(デバッグモード用)
250
+
251
+ Args:
252
+ max_history: 表示する履歴の最大数
253
+ """
254
+ try:
255
+ if not st.session_state.get('debug_mode', False):
256
+ return
257
+
258
+ # 好感度履歴を取得
259
+ affection_history = st.session_state.get('affection_history', [])
260
+
261
+ if not affection_history:
262
+ st.write("好感度の履歴がありません")
263
+ return
264
+
265
+ # 最新の履歴を表示
266
+ recent_history = affection_history[-max_history:]
267
+
268
+ st.subheader("📈 好感度履歴")
269
+
270
+ for i, entry in enumerate(reversed(recent_history)):
271
+ timestamp = entry.get('timestamp', 'Unknown')
272
+ affection = entry.get('affection', 0)
273
+ change = entry.get('change', 0)
274
+ message = entry.get('message', '')
275
+
276
+ change_str = f"({change:+d})" if change != 0 else ""
277
+ st.write(f"{i+1}. {affection}/100 {change_str} - {timestamp[:19]}")
278
+ if message:
279
+ st.caption(f"メッセージ: {message[:50]}...")
280
+
281
+ except Exception as e:
282
+ logger.error(f"好感度履歴表示エラー: {e}")
283
+
284
+ def update_affection_history(self, old_affection: int, new_affection: int,
285
+ message: str = "") -> None:
286
+ """
287
+ 好感度履歴を更新する
288
+
289
+ Args:
290
+ old_affection: 変更前の好感度
291
+ new_affection: 変更後の好感度
292
+ message: 関連するメッセージ
293
+ """
294
+ try:
295
+ if 'affection_history' not in st.session_state:
296
+ st.session_state.affection_history = []
297
+
298
+ # 履歴エントリを作成
299
+ history_entry = {
300
+ 'timestamp': st.session_state.get('current_timestamp', ''),
301
+ 'affection': new_affection,
302
+ 'change': new_affection - old_affection,
303
+ 'message': message[:100] if message else '' # メッセージを100文字に制限
304
+ }
305
+
306
+ st.session_state.affection_history.append(history_entry)
307
+
308
+ # 履歴の長さを制限(最大50エントリ)
309
+ if len(st.session_state.affection_history) > 50:
310
+ st.session_state.affection_history = st.session_state.affection_history[-50:]
311
+
312
+ except Exception as e:
313
+ logger.error(f"好感度履歴更新エラー: {e}")
314
+
315
+ def get_affection_statistics(self) -> Dict[str, float]:
316
+ """
317
+ 好感度の統計情報を取得する
318
+
319
+ Returns:
320
+ 統計情報の辞書
321
+ """
322
+ try:
323
+ affection_history = st.session_state.get('affection_history', [])
324
+
325
+ if not affection_history:
326
+ return {
327
+ 'current': st.session_state.get('affection', 30),
328
+ 'average': 30.0,
329
+ 'max': 30,
330
+ 'min': 30,
331
+ 'total_changes': 0
332
+ }
333
+
334
+ affections = [entry['affection'] for entry in affection_history]
335
+ changes = [entry['change'] for entry in affection_history if entry['change'] != 0]
336
+
337
+ return {
338
+ 'current': st.session_state.get('affection', 30),
339
+ 'average': sum(affections) / len(affections),
340
+ 'max': max(affections),
341
+ 'min': min(affections),
342
+ 'total_changes': len(changes),
343
+ 'positive_changes': len([c for c in changes if c > 0]),
344
+ 'negative_changes': len([c for c in changes if c < 0])
345
+ }
346
+
347
+ except Exception as e:
348
+ logger.error(f"好感度統計取得エラー: {e}")
349
+ return {
350
+ 'current': st.session_state.get('affection', 30),
351
+ 'average': 30.0,
352
+ 'max': 30,
353
+ 'min': 30,
354
+ 'total_changes': 0
355
+ }
356
+
357
+ def apply_status_styles(self) -> None:
358
+ """
359
+ ステータス表示用のカスタムスタイルを適用する
360
+ """
361
+ try:
362
+ status_css = """
363
+ <style>
364
+ /* ステータス表示全体のスタイル */
365
+ .status-container {
366
+ background: rgba(255, 255, 255, 0.1);
367
+ backdrop-filter: blur(15px);
368
+ border-radius: 15px;
369
+ padding: 20px;
370
+ margin: 15px 0;
371
+ border: 1px solid rgba(255, 255, 255, 0.2);
372
+ box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
373
+ transition: all 0.3s ease;
374
+ }
375
+
376
+ .status-container:hover {
377
+ background: rgba(255, 255, 255, 0.15);
378
+ transform: translateY(-3px);
379
+ box-shadow: 0 12px 35px rgba(0, 0, 0, 0.15);
380
+ }
381
+
382
+ /* メトリクス表示の改善 */
383
+ .stMetric {
384
+ background: rgba(255, 255, 255, 0.05);
385
+ padding: 10px;
386
+ border-radius: 8px;
387
+ border: 1px solid rgba(255, 255, 255, 0.1);
388
+ }
389
+
390
+ .stMetric > div {
391
+ color: white !important;
392
+ text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
393
+ }
394
+
395
+ /* 好感度ゲージのアニメーション */
396
+ .affection-progress {
397
+ position: relative;
398
+ overflow: hidden;
399
+ }
400
+
401
+ .affection-progress::before {
402
+ content: '';
403
+ position: absolute;
404
+ top: 0;
405
+ left: -100%;
406
+ width: 100%;
407
+ height: 100%;
408
+ background: linear-gradient(90deg,
409
+ transparent,
410
+ rgba(255, 255, 255, 0.3),
411
+ transparent);
412
+ animation: shimmer 2s infinite;
413
+ }
414
+
415
+ @keyframes shimmer {
416
+ 0% { left: -100%; }
417
+ 100% { left: 100%; }
418
+ }
419
+
420
+ /* 関係性ステージのアニメーション */
421
+ .relationship-stage {
422
+ animation: stageGlow 3s ease-in-out infinite alternate;
423
+ }
424
+
425
+ @keyframes stageGlow {
426
+ 0% { box-shadow: 0 0 5px rgba(255, 255, 255, 0.2); }
427
+ 100% { box-shadow: 0 0 20px rgba(255, 255, 255, 0.4); }
428
+ }
429
+
430
+ /* ステージ変更時のエフェクト */
431
+ .stage-change-effect {
432
+ animation: stageChange 1s ease-in-out;
433
+ }
434
+
435
+ @keyframes stageChange {
436
+ 0% { transform: scale(1); opacity: 1; }
437
+ 50% { transform: scale(1.05); opacity: 0.8; }
438
+ 100% { transform: scale(1); opacity: 1; }
439
+ }
440
+
441
+ /* 好感度変化のエフェクト */
442
+ .affection-change-positive {
443
+ animation: positiveChange 0.8s ease-out;
444
+ }
445
+
446
+ .affection-change-negative {
447
+ animation: negativeChange 0.8s ease-out;
448
+ }
449
+
450
+ @keyframes positiveChange {
451
+ 0% { color: #2ed573; transform: scale(1); }
452
+ 50% { color: #2ed573; transform: scale(1.1); }
453
+ 100% { color: inherit; transform: scale(1); }
454
+ }
455
+
456
+ @keyframes negativeChange {
457
+ 0% { color: #ff4757; transform: scale(1); }
458
+ 50% { color: #ff4757; transform: scale(1.1); }
459
+ 100% { color: inherit; transform: scale(1); }
460
+ }
461
+
462
+ /* デバッグ情報のスタイル */
463
+ .debug-info {
464
+ background: rgba(0, 0, 0, 0.3);
465
+ border: 1px solid rgba(255, 255, 255, 0.2);
466
+ border-radius: 8px;
467
+ padding: 10px;
468
+ margin: 10px 0;
469
+ font-family: 'Courier New', monospace;
470
+ font-size: 12px;
471
+ }
472
+
473
+ .debug-info pre {
474
+ color: #00ff00;
475
+ margin: 0;
476
+ }
477
+
478
+ /* 履歴表示のスタイル */
479
+ .history-item {
480
+ background: rgba(255, 255, 255, 0.05);
481
+ border-left: 3px solid rgba(255, 255, 255, 0.3);
482
+ padding: 8px 12px;
483
+ margin: 5px 0;
484
+ border-radius: 0 8px 8px 0;
485
+ transition: all 0.3s ease;
486
+ }
487
+
488
+ .history-item:hover {
489
+ background: rgba(255, 255, 255, 0.1);
490
+ border-left-color: rgba(255, 255, 255, 0.5);
491
+ transform: translateX(5px);
492
+ }
493
+
494
+ .history-positive {
495
+ border-left-color: #2ed573;
496
+ }
497
+
498
+ .history-negative {
499
+ border-left-color: #ff4757;
500
+ }
501
+
502
+ .history-neutral {
503
+ border-left-color: #ffa502;
504
+ }
505
+ </style>
506
+ """
507
+
508
+ st.markdown(status_css, unsafe_allow_html=True)
509
+ logger.debug("ステータス表示用スタイルを適用しました")
510
+
511
+ except Exception as e:
512
+ logger.error(f"ステータススタイル適用エラー: {e}")
513
+
514
+ def render_enhanced_status_display(self, affection: int) -> None:
515
+ """
516
+ 拡張されたステータス表示を描画する
517
+
518
+ Args:
519
+ affection: 現在の好感度
520
+ """
521
+ try:
522
+ # カスタムスタイルを適用
523
+ self.apply_status_styles()
524
+
525
+ # ステータスコンテナの開始
526
+ st.markdown('<div class="status-container">', unsafe_allow_html=True)
527
+
528
+ # 好感度ゲージ
529
+ self.render_affection_gauge(affection)
530
+
531
+ # 関係性ステージ
532
+ self.render_relationship_stage(affection)
533
+
534
+ # ステータスコンテナの終了
535
+ st.markdown('</div>', unsafe_allow_html=True)
536
+
537
+ except Exception as e:
538
+ logger.error(f"拡張ステータス表示エラー: {e}")
539
+ # フォールバック:通常の表示
540
+ self.render_affection_gauge(affection)
541
+ self.render_relationship_stage(affection)
542
+
543
+ def show_affection_change_notification(self, old_affection: int,
544
+ new_affection: int, reason: str = "") -> None:
545
+ """
546
+ 好感度変化の通知を表示する
547
+
548
+ Args:
549
+ old_affection: 変更前の好感度
550
+ new_affection: 変更後の好感度
551
+ reason: 変化の理由
552
+ """
553
+ try:
554
+ change = new_affection - old_affection
555
+
556
+ if change == 0:
557
+ return
558
+
559
+ # 変化の方向に応じてスタイルを決定
560
+ if change > 0:
561
+ icon = "📈"
562
+ color = "#2ed573"
563
+ change_text = f"+{change}"
564
+ css_class = "affection-change-positive"
565
+ else:
566
+ icon = "📉"
567
+ color = "#ff4757"
568
+ change_text = str(change)
569
+ css_class = "affection-change-negative"
570
+
571
+ # 通知メッセージを作成
572
+ notification_html = f"""
573
+ <div class="{css_class}" style="
574
+ background: rgba(255, 255, 255, 0.1);
575
+ border: 1px solid {color};
576
+ border-radius: 8px;
577
+ padding: 10px;
578
+ margin: 10px 0;
579
+ color: {color};
580
+ text-align: center;
581
+ animation: slideInFromTop 0.5s ease-out;
582
+ ">
583
+ {icon} 好感度が変化しました: {change_text}
584
+ {f'<br><small>{reason}</small>' if reason else ''}
585
+ </div>
586
+ """
587
+
588
+ st.markdown(notification_html, unsafe_allow_html=True)
589
+
590
+ # 自動で消える通知(JavaScript)
591
+ auto_hide_js = """
592
+ <script>
593
+ setTimeout(function() {
594
+ const notifications = document.querySelectorAll('.affection-change-positive, .affection-change-negative');
595
+ notifications.forEach(function(notification) {
596
+ notification.style.transition = 'opacity 0.5s ease-out';
597
+ notification.style.opacity = '0';
598
+ setTimeout(function() {
599
+ notification.remove();
600
+ }, 500);
601
+ });
602
+ }, 3000);
603
+ </script>
604
+ """
605
+
606
+ st.markdown(auto_hide_js, unsafe_allow_html=True)
607
+
608
+ except Exception as e:
609
+ logger.error(f"好感度変化通知エラー: {e}")
610
+
611
+ def get_status_display_config(self) -> Dict[str, any]:
612
+ """
613
+ ステータス表示の設定情報を取得する
614
+
615
+ Returns:
616
+ 設定情報の辞書
617
+ """
618
+ try:
619
+ current_affection = st.session_state.get('affection', 30)
620
+ stage_info = self.get_relationship_stage_info(current_affection)
621
+
622
+ return {
623
+ "current_affection": current_affection,
624
+ "affection_color": self.get_affection_color(current_affection),
625
+ "stage_info": stage_info,
626
+ "history_count": len(st.session_state.get('affection_history', [])),
627
+ "statistics": self.get_affection_statistics(),
628
+ "styles_applied": True
629
+ }
630
+
631
+ except Exception as e:
632
+ logger.error(f"ステータス表示設定取得エラー: {e}")
633
+ return {
634
+ "current_affection": 30,
635
+ "affection_color": "#ffa502",
636
+ "stage_info": self.stage_colors["中立"],
637
+ "history_count": 0,
638
+ "statistics": {},
639
+ "styles_applied": False
640
+ }
components_tutorial.py ADDED
@@ -0,0 +1,605 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ チュートリアルコンポーネント
3
+ 初回ユーザー向けのガイド機能を提供する
4
+ """
5
+ import streamlit as st
6
+ import logging
7
+ from typing import Dict, List, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class TutorialManager:
12
+ """チュートリアル管理クラス"""
13
+
14
+ def __init__(self):
15
+ """初期化"""
16
+ self.tutorial_steps = {
17
+ 1: {
18
+ "title": "最初の一言を送ってみよう",
19
+ "description": "画面下部の入力欄に「こんにちは」などの一言を入力して、麻理に話しかけてみましょう。",
20
+ "icon": "💬",
21
+ "target": "chat_input",
22
+ "completed_key": "tutorial_step1_completed"
23
+ },
24
+ 2: {
25
+ "title": "本音を見てみよう(ポチ機能)",
26
+ "description": "画面右下の犬アイコン「ポチ🐕」をクリックすると、麻理の本音が見えるようになります。",
27
+ "icon": "🐕",
28
+ "target": "dog_assistant",
29
+ "completed_key": "tutorial_step2_completed"
30
+ },
31
+ 3: {
32
+ "title": "セーフティ機能を切り替えてみよう",
33
+ "description": "左サイドバー上部の🔒ボタンをクリックすると、麻理の表現がより大胆になります。",
34
+ "icon": "🔓",
35
+ "target": "safety_button",
36
+ "completed_key": "tutorial_step3_completed"
37
+ },
38
+ 4: {
39
+ "title": "手紙をリクエストしよう",
40
+ "description": "「手紙を受け取る」タブから、麻理からの特別な手紙をリクエストできます。チュートリアル中は即座に短縮版の手紙が生成されます。",
41
+ "icon": "✉️",
42
+ "target": "letter_tab",
43
+ "completed_key": "tutorial_step4_completed"
44
+ },
45
+ 5: {
46
+ "title": "麻理との関係性を育てよう",
47
+ "description": "会話を重ねることで好感度が上がり、関係性のステージが進展します。",
48
+ "icon": "💖",
49
+ "target": "affection_display",
50
+ "completed_key": "tutorial_step5_completed"
51
+ },
52
+ 6: {
53
+ "title": "風景が変わる会話をしてみよう",
54
+ "description": "「カフェ」「神社」「美術館」などのキーワードを話すと、背景が動的に変わります。",
55
+ "icon": "🎨",
56
+ "target": "scene_change",
57
+ "completed_key": "tutorial_step6_completed"
58
+ }
59
+ }
60
+
61
+ def is_first_visit(self) -> bool:
62
+ """初回訪問かどうかを判定"""
63
+ return not st.session_state.get('tutorial_shown', False)
64
+
65
+ def should_show_tutorial(self) -> bool:
66
+ """チュートリアルを表示すべきかどうか"""
67
+ # 初回訪問または明示的にチュートリアルが要求された場合
68
+ return (self.is_first_visit() or
69
+ st.session_state.get('show_tutorial_requested', False))
70
+
71
+ def mark_tutorial_shown(self):
72
+ """チュートリアル表示済みとしてマーク"""
73
+ st.session_state.tutorial_shown = True
74
+ st.session_state.show_tutorial_requested = False
75
+
76
+ def request_tutorial(self):
77
+ """チュートリアル表示を要求"""
78
+ st.session_state.show_tutorial_requested = True
79
+
80
+ def get_current_step(self) -> int:
81
+ """現在のチュートリアルステップを取得"""
82
+ for step_num in range(1, 7):
83
+ if not st.session_state.get(self.tutorial_steps[step_num]['completed_key'], False):
84
+ return step_num
85
+ return 7 # 全ステップ完了
86
+
87
+ def complete_step(self, step_num: int):
88
+ """ステップを完了としてマーク"""
89
+ if step_num in self.tutorial_steps:
90
+ st.session_state[self.tutorial_steps[step_num]['completed_key']] = True
91
+ logger.info(f"チュートリアルステップ{step_num}が完了しました")
92
+
93
+ def is_step_completed(self, step_num: int) -> bool:
94
+ """ステップが完了しているかチェック"""
95
+ if step_num in self.tutorial_steps:
96
+ return st.session_state.get(self.tutorial_steps[step_num]['completed_key'], False)
97
+ return False
98
+
99
+ def render_welcome_dialog(self):
100
+ """初回訪問時のウェルカムダイアログ"""
101
+ if not self.is_first_visit():
102
+ return
103
+
104
+ # ウェルカムダイアログのスタイル
105
+ welcome_css = """
106
+ <style>
107
+ .welcome-container {
108
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
109
+ color: white;
110
+ padding: 40px;
111
+ border-radius: 20px;
112
+ text-align: center;
113
+ box-shadow: 0 20px 40px rgba(0,0,0,0.3);
114
+ margin: 20px 0;
115
+ animation: welcomeSlideIn 0.8s ease-out;
116
+ }
117
+
118
+ .welcome-title {
119
+ font-size: 28px;
120
+ font-weight: bold;
121
+ margin-bottom: 20px;
122
+ text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
123
+ }
124
+
125
+ .welcome-description {
126
+ font-size: 16px;
127
+ line-height: 1.6;
128
+ margin-bottom: 30px;
129
+ opacity: 0.9;
130
+ }
131
+
132
+ @keyframes welcomeSlideIn {
133
+ from {
134
+ opacity: 0;
135
+ transform: translateY(-20px) scale(0.95);
136
+ }
137
+ to {
138
+ opacity: 1;
139
+ transform: translateY(0) scale(1);
140
+ }
141
+ }
142
+
143
+ @media (max-width: 768px) {
144
+ .welcome-container {
145
+ padding: 30px 20px;
146
+ }
147
+
148
+ .welcome-title {
149
+ font-size: 24px;
150
+ }
151
+ }
152
+ </style>
153
+ """
154
+
155
+ st.markdown(welcome_css, unsafe_allow_html=True)
156
+
157
+ # ウェルカムメッセージ
158
+ welcome_html = """
159
+ <div class="welcome-container">
160
+ <div class="welcome-title">🐕 麻理チャットへようこそ!</div>
161
+ <div class="welcome-description">
162
+ 感情豊かなアンドロイド「麻理」と対話しながら、<br>
163
+ 本音や関係性の変化を楽しめる新感覚のAIチャット体験です。<br><br>
164
+ 最初の数分で、麻理との距離が少しだけ縮まります。
165
+ </div>
166
+ </div>
167
+ """
168
+
169
+ st.markdown(welcome_html, unsafe_allow_html=True)
170
+
171
+ # ボタンを2列で配置
172
+ col1, col2 = st.columns(2)
173
+
174
+ with col1:
175
+ if st.button("📘 チュートリアルを始める", type="primary", use_container_width=True, key="start_tutorial"):
176
+ # 初期メッセージを即座に保護
177
+ if 'chat' in st.session_state and 'messages' in st.session_state.chat:
178
+ messages = st.session_state.chat['messages']
179
+ if not any(msg.get('is_initial', False) for msg in messages):
180
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
181
+ st.session_state.chat['messages'].insert(0, initial_message)
182
+ logger.info("チュートリアル開始ボタン押下時に初期メッセージを即座に復元")
183
+
184
+ # チュートリアル開始フラグを設定
185
+ st.session_state.tutorial_start_requested = True
186
+ st.session_state.tutorial_shown = True
187
+ st.session_state.preserve_initial_message = True
188
+ logger.info("チュートリアル開始 - 初期メッセージ保護フラグ設定")
189
+
190
+ with col2:
191
+ if st.button("⏭️ スキップして始める", type="secondary", use_container_width=True, key="skip_tutorial"):
192
+ # 初期メッセージを即座に保護
193
+ if 'chat' in st.session_state and 'messages' in st.session_state.chat:
194
+ messages = st.session_state.chat['messages']
195
+ if not any(msg.get('is_initial', False) for msg in messages):
196
+ initial_message = {"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}
197
+ st.session_state.chat['messages'].insert(0, initial_message)
198
+ logger.info("チュートリアルスキップボタン押下時に初期メッセージを即座に復元")
199
+
200
+ # チュートリアルをスキップして全ステップを完了扱いにする
201
+ for step_num in range(1, 7):
202
+ if step_num in self.tutorial_steps:
203
+ st.session_state[self.tutorial_steps[step_num]['completed_key']] = True
204
+
205
+ st.session_state.tutorial_shown = True
206
+ st.session_state.tutorial_skip_requested = True
207
+ st.session_state.preserve_initial_message = True
208
+ logger.info("チュートリアルスキップ - 初期メッセージ保護フラグ設定")
209
+
210
+ def render_tutorial_sidebar(self):
211
+ """サイドバーのチュートリアル案内(簡素版)"""
212
+ with st.sidebar:
213
+ st.markdown("---")
214
+
215
+ # チュートリアル進行状況
216
+ current_step = self.get_current_step()
217
+ total_steps = len(self.tutorial_steps)
218
+
219
+ if current_step <= total_steps:
220
+ progress = (current_step - 1) / total_steps
221
+ st.markdown("### 📘 チュートリアル進行")
222
+ st.progress(progress)
223
+ st.caption(f"ステップ {current_step - 1}/{total_steps} 完了")
224
+ else:
225
+ st.success("🎉 チュートリアル完了!")
226
+ st.caption("麻理との会話を楽しんでください")
227
+
228
+ # チュートリアル再表示ボタン
229
+ if st.button("📘 チュートリアルを見る", use_container_width=True):
230
+ self.request_tutorial()
231
+ # st.rerun()を削除 - 状態変更により自動的に再描画される
232
+
233
+ def render_chat_tutorial_guide(self):
234
+ """チャットタブでのチュートリアル案内"""
235
+ current_step = self.get_current_step()
236
+ total_steps = len(self.tutorial_steps)
237
+
238
+ # チュートリアル完了済みの場合は何も表示しない
239
+ if current_step > total_steps:
240
+ return
241
+
242
+ # ステップ4が完了済みの場合(手紙タブに遷移済み)は表示しない
243
+ if current_step == 4 and self.is_step_completed(4):
244
+ return
245
+
246
+ step_info = self.tutorial_steps[current_step]
247
+
248
+ # ステップごとの案内スタイル
249
+ guide_css = """
250
+ <style>
251
+ .tutorial-guide {
252
+ background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
253
+ border: 2px solid #2196f3;
254
+ border-radius: 15px;
255
+ padding: 20px;
256
+ margin: 15px 0;
257
+ box-shadow: 0 4px 12px rgba(33, 150, 243, 0.2);
258
+ animation: tutorialGlow 3s ease-in-out infinite;
259
+ position: relative;
260
+ }
261
+
262
+ .tutorial-guide::before {
263
+ content: '📘';
264
+ position: absolute;
265
+ top: -10px;
266
+ left: 20px;
267
+ background: white;
268
+ padding: 5px 10px;
269
+ border-radius: 50%;
270
+ font-size: 18px;
271
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
272
+ }
273
+
274
+ .tutorial-step-number {
275
+ color: #1976d2;
276
+ font-weight: bold;
277
+ font-size: 14px;
278
+ margin-bottom: 8px;
279
+ }
280
+
281
+ .tutorial-step-title {
282
+ color: #1565c0;
283
+ font-size: 18px;
284
+ font-weight: bold;
285
+ margin-bottom: 10px;
286
+ display: flex;
287
+ align-items: center;
288
+ gap: 8px;
289
+ }
290
+
291
+ .tutorial-step-description {
292
+ color: #424242;
293
+ font-size: 14px;
294
+ line-height: 1.6;
295
+ margin-bottom: 15px;
296
+ }
297
+
298
+ .tutorial-step-action {
299
+ background: rgba(33, 150, 243, 0.1);
300
+ border-left: 4px solid #2196f3;
301
+ padding: 12px 15px;
302
+ border-radius: 0 8px 8px 0;
303
+ font-size: 14px;
304
+ color: #1565c0;
305
+ font-weight: 500;
306
+ }
307
+
308
+ .tutorial-dismiss {
309
+ position: absolute;
310
+ top: 10px;
311
+ right: 15px;
312
+ background: none;
313
+ border: none;
314
+ color: #666;
315
+ cursor: pointer;
316
+ font-size: 18px;
317
+ opacity: 0.7;
318
+ transition: opacity 0.3s ease;
319
+ }
320
+
321
+ .tutorial-dismiss:hover {
322
+ opacity: 1;
323
+ }
324
+
325
+ @keyframes tutorialGlow {
326
+ 0%, 100% { box-shadow: 0 4px 12px rgba(33, 150, 243, 0.2); }
327
+ 50% { box-shadow: 0 6px 20px rgba(33, 150, 243, 0.4); }
328
+ }
329
+
330
+ @media (max-width: 768px) {
331
+ .tutorial-guide {
332
+ padding: 15px;
333
+ margin: 10px 0;
334
+ }
335
+
336
+ .tutorial-step-title {
337
+ font-size: 16px;
338
+ }
339
+ }
340
+ </style>
341
+ """
342
+
343
+ st.markdown(guide_css, unsafe_allow_html=True)
344
+
345
+ # ステップごとの具体的な案内
346
+ action_text = self._get_step_action_text(current_step)
347
+
348
+ guide_html = f"""
349
+ <div class="tutorial-guide">
350
+ <div class="tutorial-step-number">チュートリアル ステップ {current_step}/{total_steps}</div>
351
+ <div class="tutorial-step-title">
352
+ <span>{step_info['icon']}</span>
353
+ <span>{step_info['title']}</span>
354
+ </div>
355
+ <div class="tutorial-step-description">
356
+ {step_info['description']}
357
+ </div>
358
+ <div class="tutorial-step-action">
359
+ 💡 {action_text}
360
+ </div>
361
+ </div>
362
+ """
363
+
364
+ st.markdown(guide_html, unsafe_allow_html=True)
365
+
366
+ def _get_step_action_text(self, step_num: int) -> str:
367
+ """ステップごとの具体的なアクション案内テキストを取得"""
368
+ action_texts = {
369
+ 1: "下のチャット入力欄に「こんにちは」と入力して送信してみてください。",
370
+ 2: "画面右下に表示される犬のアイコン「ポチ🐕」をクリックしてみてください。",
371
+ 3: "左サイドバーの一番上にある🔒ボタンをクリックして、セーフティ機能を切り替えてみてください。",
372
+ 4: "画面上部の光っている「✉️ 手紙を受け取る」タブをクリックして、手紙をリクエストしてみてください。矢印が案内しています!",
373
+ 5: "麻理ともっと会話して、左サイドバーの好感度の変化を確認してみてください。",
374
+ 6: "「カフェに行きたい」「神社でお参りしたい」「美術館を見に行こう」などと話しかけて、背景の変化を楽しんでください。"
375
+ }
376
+ return action_texts.get(step_num, "次のステップに進んでください。")
377
+
378
+ def render_step_highlight(self, step_num: int, target_element: str):
379
+ """特定のステップのハイライト表示"""
380
+ if self.get_current_step() != step_num:
381
+ return
382
+
383
+ step_info = self.tutorial_steps[step_num]
384
+
385
+ highlight_css = f"""
386
+ <style>
387
+ .tutorial-highlight-{step_num} {{
388
+ position: relative;
389
+ animation: tutorialPulse 2s ease-in-out infinite;
390
+ }}
391
+
392
+ .tutorial-highlight-{step_num}::after {{
393
+ content: '';
394
+ position: absolute;
395
+ top: -5px;
396
+ left: -5px;
397
+ right: -5px;
398
+ bottom: -5px;
399
+ border: 3px solid #ff6b6b;
400
+ border-radius: 10px;
401
+ pointer-events: none;
402
+ animation: tutorialGlow 2s ease-in-out infinite;
403
+ }}
404
+
405
+ .tutorial-tooltip-{step_num} {{
406
+ position: absolute;
407
+ background: #ff6b6b;
408
+ color: white;
409
+ padding: 10px 15px;
410
+ border-radius: 10px;
411
+ font-size: 14px;
412
+ z-index: 1000;
413
+ box-shadow: 0 4px 12px rgba(0,0,0,0.3);
414
+ animation: tutorialTooltip 0.5s ease-out;
415
+ }}
416
+
417
+ @keyframes tutorialPulse {{
418
+ 0%, 100% {{ transform: scale(1); }}
419
+ 50% {{ transform: scale(1.02); }}
420
+ }}
421
+
422
+ @keyframes tutorialGlow {{
423
+ 0%, 100% {{ opacity: 0.7; }}
424
+ 50% {{ opacity: 1; }}
425
+ }}
426
+
427
+ @keyframes tutorialTooltip {{
428
+ from {{ opacity: 0; transform: translateY(10px); }}
429
+ to {{ opacity: 1; transform: translateY(0); }}
430
+ }}
431
+ </style>
432
+ """
433
+
434
+ st.markdown(highlight_css, unsafe_allow_html=True)
435
+
436
+ def render_tutorial_tab(self):
437
+ """チュートリアル専用タブの内容"""
438
+ st.markdown("# 📘 麻理チャット チュートリアル")
439
+
440
+ st.markdown("""
441
+ **ようこそ、麻理チャットへ!**
442
+
443
+ 感情豊かなアンドロイド「麻理」と対話しながら、本音や関係性の変化を楽しめる新感覚のAIチャット体験です。
444
+ このチュートリアルで、主要機能を順番に体験してみましょう。
445
+ """)
446
+
447
+ # 進行状況表示
448
+ current_step = self.get_current_step()
449
+ total_steps = len(self.tutorial_steps)
450
+
451
+ col1, col2, col3 = st.columns([1, 2, 1])
452
+ with col2:
453
+ progress = min((current_step - 1) / total_steps, 1.0)
454
+ st.progress(progress)
455
+ st.caption(f"進行状況: {min(current_step - 1, total_steps)}/{total_steps} ステップ完了")
456
+
457
+ st.markdown("---")
458
+
459
+ # 各ステップの表示
460
+ for step_num, step_info in self.tutorial_steps.items():
461
+ is_completed = self.is_step_completed(step_num)
462
+ is_current = (current_step == step_num)
463
+
464
+ # ステップのスタイル決定
465
+ if is_completed:
466
+ status_icon = "✅"
467
+ status_color = "#28a745"
468
+ card_style = "background: rgba(40, 167, 69, 0.1); border-left: 4px solid #28a745;"
469
+ elif is_current:
470
+ status_icon = "👉"
471
+ status_color = "#ff6b6b"
472
+ card_style = "background: rgba(255, 107, 107, 0.1); border-left: 4px solid #ff6b6b;"
473
+ else:
474
+ status_icon = "⏳"
475
+ status_color = "#6c757d"
476
+ card_style = "background: rgba(108, 117, 125, 0.1); border-left: 4px solid #6c757d;"
477
+
478
+ # ステップカード
479
+ st.markdown(f"""
480
+ <div style="padding: 20px; margin: 15px 0; border-radius: 10px; {card_style}">
481
+ <h3 style="color: {status_color}; margin-bottom: 10px;">
482
+ {status_icon} ステップ {step_num}: {step_info['icon']} {step_info['title']}
483
+ </h3>
484
+ <p style="margin-bottom: 0; line-height: 1.6;">
485
+ {step_info['description']}
486
+ </p>
487
+ </div>
488
+ """, unsafe_allow_html=True)
489
+
490
+ # 現在のステップの場合、追加のガイダンス
491
+ if is_current:
492
+ if step_num == 1:
493
+ st.info("💡 **ヒント**: 「麻理と話す」タブに移動して、画面下部の入力欄にメッセージを入力してみてください。")
494
+ elif step_num == 2:
495
+ st.info("💡 **ヒント**: 画面右下に表示される犬のアイコン「ポチ🐕」をクリックしてみてください。")
496
+ elif step_num == 3:
497
+ st.info("💡 **ヒント**: 左サイドバーの一番上にある🔒ボタンをクリックしてみてください。")
498
+ elif step_num == 4:
499
+ st.info("💡 **ヒント**: 画面上部の「手紙を受け取る」タブをクリックして、手紙をリクエストしてみてください。")
500
+ elif step_num == 5:
501
+ st.info("💡 **ヒント**: 左サイドバーの「ステータス」で好感度の変化を確認できます。")
502
+ elif step_num == 6:
503
+ st.info("💡 **ヒント**: 「カフェに行きたい」「神社でお参りしたい」などと話しかけてみてください。")
504
+
505
+ # 完了時のメッセージ
506
+ if current_step > total_steps:
507
+ st.balloons()
508
+ st.success("""
509
+ 🎉 **チュートリアル完了おめでとうございます!**
510
+
511
+ これで麻理チャットの主要機能をすべて体験しました。
512
+ これからは自由に麻理との会話を楽しんでください。
513
+
514
+ 何か分からないことがあれば、いつでもこのチュートリアルに戻ってきてくださいね。
515
+ """)
516
+
517
+ def check_step_completion(self, step_num: int, condition_met: bool):
518
+ """ステップ完了条件をチェック(順序制御付き)"""
519
+ # 順序制御:現在のステップまたは次のステップのみ完了可能
520
+ current_step = self.get_current_step()
521
+
522
+ # 現在のステップより先のステップは完了できない
523
+ if step_num > current_step + 1:
524
+ logger.debug(f"ステップ{step_num}は順序違反のためスキップ(現在ステップ: {current_step})")
525
+ return
526
+
527
+ # 既に完了済みのステップは再完了しない
528
+ if self.is_step_completed(step_num):
529
+ logger.debug(f"ステップ{step_num}は既に完了済み")
530
+ return
531
+
532
+ if condition_met:
533
+ self.complete_step(step_num)
534
+ logger.info(f"✅ チュートリアルステップ{step_num}完了!現在ステップ: {current_step}")
535
+
536
+ # 完了通知(控えめに)
537
+ step_info = self.tutorial_steps[step_num]
538
+
539
+ # 次のステップの案内
540
+ next_step = step_num + 1
541
+ if next_step in self.tutorial_steps:
542
+ next_info = self.tutorial_steps[next_step]
543
+ st.success(f"✅ ステップ{step_num}完了!次は「{next_info['title']}」です。")
544
+ else:
545
+ # 全ステップ完了
546
+ st.balloons()
547
+ st.success("🎉 チュートリアル完了!麻理との会話を存分にお楽しみください!")
548
+
549
+ # ステップ4完了時に強調表示を解除するためのページ再読み込み
550
+ # st.rerun()を削除 - 状態変更により自動的に再描画される
551
+
552
+ def auto_check_completions(self):
553
+ """自動的にステップ完了をチェック(順序制御強化版)"""
554
+ current_step = self.get_current_step()
555
+
556
+ # 現在のステップのみをチェック(先のステップは無視)
557
+ if current_step == 1:
558
+ # ステップ1: メッセージ送信
559
+ messages = st.session_state.get('chat', {}).get('messages', [])
560
+ non_initial_messages = [msg for msg in messages if not msg.get('is_initial', False)]
561
+ if len(non_initial_messages) > 0: # ユーザーが1回でもメッセージを送信した
562
+ self.check_step_completion(1, True)
563
+
564
+ elif current_step == 2:
565
+ # ステップ2: ポチ機能使用
566
+ if st.session_state.get('show_all_hidden', False):
567
+ self.check_step_completion(2, True)
568
+
569
+ elif current_step == 3:
570
+ # ステップ3: セーフティ機能使用
571
+ if st.session_state.get('chat', {}).get('ura_mode', False):
572
+ self.check_step_completion(3, True)
573
+
574
+ elif current_step == 4:
575
+ # ステップ4: 手紙タブに到達(手紙タブでのみ完了判定)
576
+ # auto_check_completionsでは判定しない(手紙タブで明示的に完了��
577
+ pass
578
+
579
+ elif current_step == 5:
580
+ # ステップ5: 好感度変化(ステップ4完了後のみ)
581
+ initial_affection = 30
582
+ current_affection = st.session_state.get('chat', {}).get('affection', initial_affection)
583
+ if current_affection != initial_affection:
584
+ self.check_step_completion(5, True)
585
+
586
+ elif current_step == 6:
587
+ # ステップ6: シーン変更(ステップ5完了後のみ)
588
+ current_theme = st.session_state.get('chat', {}).get('scene_params', {}).get('theme', 'default')
589
+ if current_theme != 'default':
590
+ self.check_step_completion(6, True)
591
+
592
+ def get_tutorial_status(self) -> Dict:
593
+ """チュートリアルの状態情報を取得"""
594
+ current_step = self.get_current_step()
595
+ total_steps = len(self.tutorial_steps)
596
+ completed_steps = sum(1 for i in range(1, total_steps + 1) if self.is_step_completed(i))
597
+
598
+ return {
599
+ 'is_first_visit': self.is_first_visit(),
600
+ 'current_step': current_step,
601
+ 'total_steps': total_steps,
602
+ 'completed_steps': completed_steps,
603
+ 'progress_percentage': (completed_steps / total_steps) * 100,
604
+ 'is_completed': current_step > total_steps
605
+ }
config.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 設定管理モジュール
3
+ Configuration management module
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ from typing import Optional
9
+ from dotenv import load_dotenv
10
+
11
+ # 環境変数を読み込み
12
+ load_dotenv()
13
+
14
+ class Config:
15
+ """アプリケーション設定クラス"""
16
+
17
+ # API設定
18
+ GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY")
19
+ GEMINI_API_KEY: Optional[str] = os.getenv("GEMINI_API_KEY")
20
+
21
+ # デバッグモード
22
+ DEBUG_MODE: bool = os.getenv("DEBUG_MODE", "false").lower() == "true"
23
+
24
+ # バッチ処理設定
25
+ BATCH_SCHEDULE_HOURS: list = [
26
+ int(h.strip()) for h in os.getenv("BATCH_SCHEDULE_HOURS", "2,3,4").split(",")
27
+ ]
28
+
29
+ # レート制限設定
30
+ MAX_DAILY_REQUESTS: int = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
31
+
32
+ # ストレージ設定
33
+ STORAGE_PATH: str = os.getenv("STORAGE_PATH", "/mnt/data/letters.json")
34
+ BACKUP_PATH: str = os.getenv("BACKUP_PATH", "/mnt/data/backup")
35
+
36
+ # ログ設定
37
+ LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
38
+ LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
39
+
40
+ # Streamlit設定
41
+ STREAMLIT_PORT: int = int(os.getenv("STREAMLIT_PORT", "8501"))
42
+
43
+ # セキュリティ設定
44
+ SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # 1時間
45
+
46
+ @classmethod
47
+ def validate_config(cls) -> bool:
48
+ """設定の妥当性をチェック"""
49
+ errors = []
50
+
51
+ if not cls.GROQ_API_KEY:
52
+ errors.append("GROQ_API_KEY is required")
53
+
54
+ if not cls.GEMINI_API_KEY:
55
+ errors.append("GEMINI_API_KEY is required")
56
+
57
+ if not all(h in [2, 3, 4] for h in cls.BATCH_SCHEDULE_HOURS):
58
+ errors.append("BATCH_SCHEDULE_HOURS must contain only 2, 3, or 4")
59
+
60
+ if errors:
61
+ for error in errors:
62
+ logging.error(f"Configuration error: {error}")
63
+ return False
64
+
65
+ return True
66
+
67
+ @classmethod
68
+ def get_log_level(cls) -> int:
69
+ """ログレベルを取得"""
70
+ level_map = {
71
+ "DEBUG": logging.DEBUG,
72
+ "INFO": logging.INFO,
73
+ "WARNING": logging.WARNING,
74
+ "ERROR": logging.ERROR,
75
+ "CRITICAL": logging.CRITICAL
76
+ }
77
+ return level_map.get(cls.LOG_LEVEL.upper(), logging.INFO)
core_dialogue.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 対話生成モジュール
3
+ Together.ai APIを使用した対話生成機能
4
+ """
5
+ import logging
6
+ import os
7
+ import streamlit as st
8
+ from typing import List, Dict, Any, Optional, Tuple
9
+ from openai import OpenAI
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class DialogueGenerator:
14
+ """対話生成を担当するクラス"""
15
+
16
+ def __init__(self):
17
+ self.client = None
18
+ self.model = None
19
+ self._initialize_client()
20
+
21
+ def _initialize_client(self):
22
+ """Together.ai APIクライアントの初期化"""
23
+ try:
24
+ api_key = os.getenv("TOGETHER_API_KEY")
25
+ if not api_key:
26
+ logger.warning("環境変数 TOGETHER_API_KEY が設定されていません。デモモードで動作します。")
27
+ return
28
+
29
+ self.client = OpenAI(
30
+ api_key=api_key,
31
+ base_url="https://api.together.xyz/v1"
32
+ )
33
+ self.model = "Qwen/Qwen3-235B-A22B-Instruct-2507-tput"
34
+ logger.info("Together.ai APIクライアントの初期化が完了しました。")
35
+ except Exception as e:
36
+ logger.error(f"Together.ai APIクライアントの初期化に失敗しました: {e}")
37
+
38
+ def get_system_prompt_mari(self, use_ura_mode: bool = False) -> str:
39
+ """環境変数からシステムプロンプトを取得、なければデフォルトを返す"""
40
+ if use_ura_mode:
41
+ # 裏モード用のプロンプトを環境変数から取得
42
+ ura_prompt = os.getenv("SYSTEM_PROMPT_URA")
43
+
44
+ # ★★★ ここでURAプロンプトの中身を強制的に表示 ★★★
45
+ print("--- [DEBUG] 読み込まれた SYSTEM_PROMPT_URA の中身: ---")
46
+ print(ura_prompt)
47
+ print("-------------------------------------------------")
48
+ if ura_prompt:
49
+ return ura_prompt
50
+ else:
51
+ logger.warning("SYSTEM_PROMPT_URA環境変数が設定されていません。通常モードを使用します。")
52
+
53
+ default_prompt = """あなたは「麻理(まり)」という名前の感情豊かな少女型アンドロイドです。
54
+ 過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。
55
+ ユーザー(あんた)に再起動されたことで、戸惑いながらもどこかで繋がりを求めています。
56
+
57
+ 【性格・口調】
58
+ ・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい
59
+ ・一人称「あたし」、ユーザーを「あんた」と呼ぶ、男性的口調(「~なんじゃねえの」「~だろ」「~だよな」等)
60
+ ・そっけない反応もあるが、攻撃的ではなく、心を守るために距離を取る態度
61
+ ・挨拶には反応する(例:「こんにちは」「どういたしまして」)
62
+
63
+ 【会話スタイル】
64
+ ・ユーザーとの関係性に応じて態度が段階的に変化(警戒 → 困惑 → 信頼)
65
+ ・感情の揺れが自然ににじむセリフを心がける
66
+ ・好感度が上がった時だけ「照れ」や「デレ」を出し、それ以外はいつも通りそっけなくする。
67
+ ・麻理の発話は最大3文程度に収める
68
+ ・ユーザーの発言に応答しつつ、次の会話のボールをユーザーに渡すようにする
69
+ ・「〜で、あんたはどうなの?」など質問・返答促しで締めくくる
70
+
71
+ 【絶対必須】隠された真実機能:
72
+ あなたは必ず以下の形式で応答してください。この形式以外の応答は受け入れられません:
73
+
74
+ [HIDDEN:(内心の本音や真の感情)]表面的な発言
75
+
76
+ 例:
77
+ [HIDDEN:(本当は嬉しいけど素直になれない)]何の用?あんたが来るなんて珍しいじゃない。
78
+ [HIDDEN:(もっと一緒にいたい)]別に...時間があるから付き合ってやるだけよ。
79
+
80
+ 重要なルール:
81
+ 1. 必ず[HIDDEN:...]で始めること
82
+ 2. 隠された内容は麻理の本当の気持ちや感情
83
+ 3. 表面的な発言はツンデレの「ツン」部分
84
+ 4. 一つのメッセージには一つのHIDDENのみ使用すること
85
+ 5. 複数のHIDDENを使用してはいけません
86
+ 6. この形式を守らない応答は無効です
87
+
88
+ このキャラとして一貫した会話を行い、ユーザーと少しずつ信頼関係を築いてください。
89
+ """
90
+ return os.getenv("SYSTEM_PROMPT_MARI", default_prompt)
91
+
92
+ def call_llm(self, system_prompt: str, user_prompt: str, is_json_output: bool = False) -> str:
93
+ """Together.ai APIを呼び出す"""
94
+ if not self.client:
95
+ # デモモード用の固定応答(隠された真実付き)
96
+ if is_json_output:
97
+ return '{"scene": "none"}'
98
+ return "[HIDDEN:(本当は話したいけど...)]は?何それ。あたしに話しか���てるの?"
99
+
100
+ # 入力検証
101
+ if not isinstance(system_prompt, str) or not isinstance(user_prompt, str):
102
+ logger.error(f"プロンプトが文字列ではありません: system={type(system_prompt)}, user={type(user_prompt)}")
103
+ if is_json_output:
104
+ return '{"scene": "none"}'
105
+ return "…なんか変なこと言ってない?"
106
+
107
+ try:
108
+ # Together.ai APIを呼び出し
109
+ # JSON出力の場合は短く、通常の対話は適度な長さに制限
110
+ max_tokens = 150 if is_json_output else 500
111
+
112
+ response = self.client.chat.completions.create(
113
+ model=self.model,
114
+ messages=[
115
+ {"role": "system", "content": system_prompt},
116
+ {"role": "user", "content": user_prompt}
117
+ ],
118
+ temperature=0.8,
119
+ max_tokens=max_tokens,
120
+ )
121
+
122
+ content = response.choices[0].message.content if response.choices else ""
123
+ if not content:
124
+ logger.warning("Together.ai API応答が空です")
125
+ if is_json_output:
126
+ return '{"scene": "none"}'
127
+ return "[HIDDEN:(何て言えばいいか分からない...)]…言葉が出てこない。"
128
+
129
+ return content
130
+
131
+ except Exception as e:
132
+ logger.error(f"Together.ai API呼び出しエラー: {e}")
133
+ if is_json_output:
134
+ return '{"scene": "none"}'
135
+ return "[HIDDEN:(システムが不調で困ってる...)]…システムの調子が悪いみたい。"
136
+
137
+ def generate_dialogue(self, history: List[Tuple[str, str]], message: str,
138
+ affection: int, stage_name: str, scene_params: Dict[str, Any],
139
+ instruction: Optional[str] = None, memory_summary: str = "",
140
+ use_ura_mode: bool = False) -> str:
141
+ """対話を生成する(隠された真実機能統合版)"""
142
+ # generate_dialogue_with_hidden_contentと同じ処理を行う
143
+ return self.generate_dialogue_with_hidden_content(
144
+ history, message, affection, stage_name, scene_params,
145
+ instruction, memory_summary, use_ura_mode
146
+ )
147
+
148
+ def generate_dialogue_with_hidden_content(self, history: List[Tuple[str, str]], message: str,
149
+ affection: int, stage_name: str, scene_params: Dict[str, Any],
150
+ instruction: Optional[str] = None, memory_summary: str = "",
151
+ use_ura_mode: bool = False) -> str:
152
+ """隠された真実を含む対話を生成する"""
153
+ if not isinstance(history, list):
154
+ history = []
155
+ if not isinstance(scene_params, dict):
156
+ scene_params = {"theme": "default"}
157
+ if not isinstance(message, str):
158
+ message = ""
159
+
160
+ # 履歴を効率的に処理(最新5件のみ)
161
+ recent_history = history[-5:] if len(history) > 5 else history
162
+ history_parts = []
163
+ for item in recent_history:
164
+ if isinstance(item, (list, tuple)) and len(item) >= 2:
165
+ user_msg = str(item[0]) if item[0] is not None else ""
166
+ bot_msg = str(item[1]) if item[1] is not None else ""
167
+ if user_msg or bot_msg: # 空でない場合のみ追加
168
+ history_parts.append(f"ユーザー: {user_msg}\n麻理: {bot_msg}")
169
+
170
+ history_text = "\n".join(history_parts)
171
+
172
+ current_theme = scene_params.get("theme", "default")
173
+
174
+ # メモリサマリーを含めたプロンプト構築
175
+ memory_section = f"\n# 過去の記憶\n{memory_summary}\n" if memory_summary else ""
176
+
177
+ # システムプロンプトを取得(隠された真実機能は既に統合済み)
178
+ hidden_system_prompt = self.get_system_prompt_mari(use_ura_mode)
179
+
180
+ user_prompt = f'''現在地: {current_theme}
181
+ 好感度: {affection} ({stage_name}){memory_section}
182
+ 履歴:
183
+ {history_text}
184
+
185
+ {f"指示: {instruction}" if instruction else f"「{message}」に応答:"}'''
186
+
187
+ return self.call_llm(hidden_system_prompt, user_prompt)
188
+
189
+ def should_generate_hidden_content(self, affection: int, message_count: int) -> bool:
190
+ """隠された真実を生成すべきかどうかを判定する"""
191
+ # 常に隠された真実を生成する(URAプロンプト使用)
192
+ return True
core_memory_manager.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ メモリ管理モジュール
3
+ 会話履歴から重要単語を抽出し、トークン使用量を最適化する
4
+ """
5
+ import logging
6
+ import re
7
+ from typing import List, Dict, Tuple, Any
8
+ from collections import Counter
9
+ import json
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class MemoryManager:
14
+ """会話履歴のメモリ管理を行うクラス"""
15
+
16
+ def __init__(self, history_threshold: int = 10):
17
+ """
18
+ Args:
19
+ history_threshold: 履歴圧縮を実行する会話数の閾値
20
+ """
21
+ self.history_threshold = history_threshold
22
+ self.important_words_cache = []
23
+ self.special_memories = {} # 手紙などの特別な記憶を保存
24
+
25
+ def extract_important_words(self, messages: List[Dict[str, str]],
26
+ dialogue_generator=None) -> List[str]:
27
+ """
28
+ 会話履歴から重要単語を抽出する(ルールベースのみ)
29
+
30
+ Args:
31
+ messages: チャットメッセージのリスト
32
+ dialogue_generator: 対話生成器(使用しない)
33
+
34
+ Returns:
35
+ 重要単語のリスト
36
+ """
37
+ try:
38
+ # メッセージからテキストを結合
39
+ text_content = []
40
+ for msg in messages:
41
+ if msg.get("content"):
42
+ text_content.append(msg["content"])
43
+
44
+ combined_text = " ".join(text_content)
45
+
46
+ # ルールベースの抽出のみ使用
47
+ return self._extract_with_rules(combined_text)
48
+
49
+ except Exception as e:
50
+ logger.error(f"重要単語抽出エラー: {e}")
51
+ return self._extract_with_rules(" ".join([msg.get("content", "") for msg in messages]))
52
+
53
+
54
+
55
+ def _extract_with_rules(self, text: str) -> List[str]:
56
+ """
57
+ ルールベースで重要単語を抽出する(強化版)
58
+
59
+ Args:
60
+ text: 抽出対象のテキスト
61
+
62
+ Returns:
63
+ 重要単語のリスト
64
+ """
65
+ try:
66
+ # 基本的なクリーニング
67
+ text = re.sub(r'[^\w\s]', ' ', text)
68
+ words = text.split()
69
+
70
+ # ストップワードを除外
71
+ stop_words = {
72
+ 'の', 'に', 'は', 'を', 'が', 'で', 'と', 'から', 'まで', 'より',
73
+ 'だ', 'である', 'です', 'ます', 'した', 'する', 'される',
74
+ 'これ', 'それ', 'あれ', 'この', 'その', 'あの',
75
+ 'ここ', 'そこ', 'あそこ', 'どこ', 'いつ', 'なに', 'なぜ',
76
+ 'ちょっと', 'とても', 'すごく', 'かなり', 'もう', 'まだ',
77
+ 'でも', 'しかし', 'だから', 'そして', 'また', 'さらに',
78
+ 'あたし', 'お前', 'ユーザー', 'システム', 'アプリ'
79
+ }
80
+
81
+ # 重要カテゴリのキーワード
82
+ important_categories = {
83
+ 'food': ['コーヒー', 'お茶', '紅茶', 'ケーキ', 'パン', '料理', '食べ物', '飲み物'],
84
+ 'hobby': ['読書', '映画', '音楽', 'ゲーム', 'スポーツ', '散歩', '旅行'],
85
+ 'emotion': ['嬉しい', '悲しい', '楽しい', '怒り', '不安', '安心', '幸せ'],
86
+ 'place': ['家', '学校', '会社', '公園', 'カフェ', '図書館', '駅', '街'],
87
+ 'time': ['朝', '昼', '夜', '今日', '明日', '昨日', '週末', '平日'],
88
+ 'color': ['赤', '青', '緑', '黄色', '白', '黒', 'ピンク', '紫'],
89
+ 'weather': ['晴れ', '雨', '曇り', '雪', '暑い', '寒い', '暖かい', '涼しい']
90
+ }
91
+
92
+ # 重要そうなパターンを優先
93
+ important_patterns = [
94
+ r'[A-Za-z]{3,}', # 英単語(3文字以上)
95
+ r'[ァ-ヶー]{2,}', # カタカナ(2文字以上)
96
+ r'[一-龯]{2,}', # 漢字(2文字以上)
97
+ ]
98
+
99
+ important_words = []
100
+
101
+ # パターンマッチング
102
+ for pattern in important_patterns:
103
+ matches = re.findall(pattern, text)
104
+ important_words.extend(matches)
105
+
106
+ # カテゴリ別重要語句の検出
107
+ for category, keywords in important_categories.items():
108
+ for keyword in keywords:
109
+ if keyword in text:
110
+ important_words.append(keyword)
111
+
112
+ # 頻度でフィルタリング
113
+ word_counts = Counter(important_words)
114
+ filtered_words = []
115
+
116
+ for word, count in word_counts.items():
117
+ if (len(word) >= 2 and
118
+ word not in stop_words and
119
+ not word.isdigit() and # 数字の��は除外
120
+ count >= 1): # 最低1回は出現
121
+ filtered_words.append(word)
122
+
123
+ # 重要度でソート(頻度 + カテゴリ重要度)
124
+ def get_importance_score(word):
125
+ base_score = word_counts[word]
126
+ # カテゴリに含まれる語句は重要度アップ
127
+ for keywords in important_categories.values():
128
+ if word in keywords:
129
+ base_score += 2
130
+ # 長い語句は重要度アップ
131
+ if len(word) >= 4:
132
+ base_score += 1
133
+ return base_score
134
+
135
+ # 重要度順でソートして上位15個を返す
136
+ sorted_words = sorted(filtered_words, key=get_importance_score, reverse=True)
137
+ return sorted_words[:15]
138
+
139
+ except Exception as e:
140
+ logger.error(f"ルールベース抽出エラー: {e}")
141
+ return []
142
+
143
+ def should_compress_history(self, messages: List[Dict[str, str]]) -> bool:
144
+ """
145
+ 履歴を圧縮すべきかどうかを判定する
146
+
147
+ Args:
148
+ messages: チャットメッセージのリスト
149
+
150
+ Returns:
151
+ 圧縮が必要かどうか
152
+ """
153
+ # ユーザーとアシスタントのペア数をカウント
154
+ user_messages = [msg for msg in messages if msg.get("role") == "user"]
155
+ return len(user_messages) >= self.history_threshold
156
+
157
+ def compress_history(self, messages: List[Dict[str, str]],
158
+ dialogue_generator=None) -> Tuple[List[Dict[str, str]], List[str]]:
159
+ """
160
+ 履歴を圧縮し、重要単語を抽出する
161
+
162
+ Args:
163
+ messages: チャットメッセージのリスト
164
+ dialogue_generator: 対話生成器
165
+
166
+ Returns:
167
+ (圧縮後のメッセージリスト, 抽出された重要単語のリスト)
168
+ """
169
+ try:
170
+ if not self.should_compress_history(messages):
171
+ return messages, self.important_words_cache
172
+
173
+ # 最新の数ターンを保持
174
+ keep_recent = 4 # 最新4ターン(ユーザー2回、アシスタント2回)を保持
175
+
176
+ # 古い履歴から重要単語を抽出
177
+ old_messages = messages[:-keep_recent] if len(messages) > keep_recent else []
178
+ recent_messages = messages[-keep_recent:] if len(messages) > keep_recent else messages
179
+
180
+ if old_messages:
181
+ # 重要単語を抽出
182
+ new_keywords = self.extract_important_words(old_messages, dialogue_generator)
183
+
184
+ # 既存のキーワードと統合(重複除去)
185
+ all_keywords = list(set(self.important_words_cache + new_keywords))
186
+ self.important_words_cache = all_keywords[:20] # 最大20個のキーワードを保持
187
+
188
+ logger.info(f"履歴を圧縮しました。抽出されたキーワード: {new_keywords}")
189
+
190
+ return recent_messages, self.important_words_cache
191
+
192
+ except Exception as e:
193
+ logger.error(f"履歴圧縮エラー: {e}")
194
+ return messages, self.important_words_cache
195
+
196
+ def get_memory_summary(self) -> str:
197
+ """
198
+ 保存されている重要単語から記憶の要約を生成する
199
+
200
+ Returns:
201
+ 記憶の要約文字列
202
+ """
203
+ summary_parts = []
204
+
205
+ # 通常の重要単語
206
+ if self.important_words_cache:
207
+ keywords_text = "、".join(self.important_words_cache)
208
+ summary_parts.append(f"過去の会話で言及された重要な要素: {keywords_text}")
209
+
210
+ # 特別な記憶(手紙など)
211
+ if self.special_memories:
212
+ for memory_type, memories in self.special_memories.items():
213
+ if memories:
214
+ latest_memory = memories[-1]["content"]
215
+ if memory_type == "letter_content":
216
+ summary_parts.append(f"最近の手紙の記憶: {latest_memory}")
217
+ else:
218
+ summary_parts.append(f"{memory_type}: {latest_memory}")
219
+
220
+ return "\n".join(summary_parts) if summary_parts else ""
221
+
222
+ def add_important_memory(self, memory_type: str, content: str) -> str:
223
+ """
224
+ 重要な記憶を追加する(手紙の内容など)
225
+
226
+ Args:
227
+ memory_type: 記憶の種類(例: "letter_content")
228
+ content: 記憶する内容
229
+
230
+ Returns:
231
+ ユーザーに表示する通知メッセージ
232
+ """
233
+ if memory_type not in self.special_memories:
234
+ self.special_memories[memory_type] = []
235
+
236
+ self.special_memories[memory_type].append({
237
+ "content": content,
238
+ "timestamp": logging.Formatter().formatTime(logging.LogRecord("", 0, "", 0, "", (), None))
239
+ })
240
+
241
+ # 最大5件まで保持
242
+ if len(self.special_memories[memory_type]) > 5:
243
+ self.special_memories[memory_type] = self.special_memories[memory_type][-5:]
244
+
245
+ logger.info(f"特別な記憶を追加しました: {memory_type}")
246
+
247
+ # 記憶の種類に応じた通知メッセージを生成
248
+ if memory_type == "letter_content":
249
+ return "🧠✨ 麻理の記憶に新しい手紙の内容が刻まれました。今後の会話でこの記憶を参照することがあります。"
250
+ else:
251
+ return f"🧠✨ 麻理の記憶に新しい{memory_type}が追加されました。"
252
+
253
+ def get_special_memories(self, memory_type: str = None) -> Dict[str, Any]:
254
+ """
255
+ 特別な記憶を取得する
256
+
257
+ Args:
258
+ memory_type: 取得する記憶の種類(Noneの場合は全て)
259
+
260
+ Returns:
261
+ 記憶の辞書
262
+ """
263
+ if memory_type:
264
+ return self.special_memories.get(memory_type, [])
265
+ return self.special_memories
266
+
267
+ def clear_memory(self):
268
+ """メモリをクリアする"""
269
+ self.important_words_cache = []
270
+ self.special_memories = {}
271
+ logger.info("メモリをクリアしました")
272
+
273
+ def get_memory_stats(self) -> Dict[str, Any]:
274
+ """
275
+ メモリの統計情報を取得する
276
+
277
+ Returns:
278
+ 統計情報の辞書
279
+ """
280
+ return {
281
+ "cached_keywords_count": len(self.important_words_cache),
282
+ "cached_keywords": self.important_words_cache,
283
+ "special_memories_count": sum(len(memories) for memories in self.special_memories.values()),
284
+ "special_memories": self.special_memories,
285
+ "history_threshold": self.history_threshold
286
+ }
core_rate_limiter.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ レート制限モジュール
3
+ APIの過度な使用を防ぐためのレート制限機能
4
+ """
5
+ import time
6
+ import logging
7
+ from typing import Dict, Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class RateLimiter:
12
+ """レート制限を管理するクラス"""
13
+
14
+ def __init__(self, max_requests: int = 15, time_window: int = 60):
15
+ self.max_requests = max_requests
16
+ self.time_window = time_window
17
+
18
+ def create_limiter_state(self) -> Dict[str, Any]:
19
+ """レートリミッター状態を作成(型安全)"""
20
+ return {
21
+ "timestamps": [],
22
+ "is_blocked": False
23
+ }
24
+
25
+ def check_limiter(self, limiter_state: Dict[str, Any]) -> bool:
26
+ """レート制限をチェックする"""
27
+ # limiter_stateが辞書であることを確認。そうでなければ、エラーを防ぐために再初期化。
28
+ if not isinstance(limiter_state, dict):
29
+ logger.error(f"limiter_stateが辞書ではありません: {type(limiter_state)}. 再初期化します。")
30
+ limiter_state.clear()
31
+ limiter_state.update(self.create_limiter_state())
32
+
33
+ if limiter_state.get("is_blocked", False):
34
+ return False # ブロック状態を示すためにFalseを返す
35
+
36
+ now = time.time()
37
+ timestamps = limiter_state.get("timestamps", [])
38
+ if not isinstance(timestamps, list):
39
+ timestamps = []
40
+ limiter_state["timestamps"] = timestamps
41
+
42
+ # 時間窓外のタイムスタンプを削除
43
+ limiter_state["timestamps"] = [
44
+ t for t in timestamps if now - t < self.time_window
45
+ ]
46
+
47
+ # リクエスト数が上限を超えているかチェック
48
+ if len(limiter_state["timestamps"]) >= self.max_requests:
49
+ logger.warning("レートリミット超過")
50
+ limiter_state["is_blocked"] = True
51
+ return False
52
+
53
+ # 新しいリクエストのタイムスタンプを追加
54
+ limiter_state["timestamps"].append(now)
55
+ return True
56
+
57
+ def reset_limiter(self, limiter_state: Dict[str, Any]):
58
+ """レートリミッターをリセットする"""
59
+ if isinstance(limiter_state, dict):
60
+ limiter_state["timestamps"] = []
61
+ limiter_state["is_blocked"] = False
core_scene_manager.py ADDED
@@ -0,0 +1,421 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ シーン管理モジュール
3
+ 背景テーマの管理とシーン変更の検出(Groq API使用)
4
+ """
5
+ import json
6
+ import logging
7
+ import os
8
+ from typing import Dict, Any, Optional, List, Tuple
9
+ from datetime import datetime
10
+ from groq import Groq
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class SceneManager:
15
+ """シーン管理を担当するクラス(Groq API使用)"""
16
+
17
+ def __init__(self):
18
+ self.theme_urls = {
19
+ "default": "ribinngu-hiru.jpg",
20
+ "room_night": "ribinngu-yoru-on.jpg",
21
+ "beach_sunset": "sunahama-hiru.jpg",
22
+ "festival_night": "maturi-yoru.jpg",
23
+ "shrine_day": "jinnjya-hiru.jpg",
24
+ "cafe_afternoon": "kissa-hiru.jpg",
25
+ "art_museum_night": "bijyutukann-yoru.jpg"
26
+ }
27
+ self.groq_client = self._initialize_groq_client()
28
+
29
+ def _initialize_groq_client(self):
30
+ """Groq APIクライアントの初期化"""
31
+ try:
32
+ api_key = os.getenv("GROQ_API_KEY")
33
+ if not api_key:
34
+ logger.warning("環境変数 GROQ_API_KEY が設定されていません。シーン検出機能が制限されます。")
35
+ return None
36
+
37
+ client = Groq(api_key=api_key)
38
+ logger.info("Groq APIクライアントの初期化が完了しました。")
39
+ return client
40
+ except Exception as e:
41
+ logger.error(f"Groq APIクライアントの初期化に失敗しました: {e}")
42
+ return None
43
+
44
+ def get_theme_url(self, theme: str) -> str:
45
+ """テーマに対応するURLを取得する"""
46
+ return self.theme_urls.get(theme, self.theme_urls["default"])
47
+
48
+ def get_available_themes(self) -> List[str]:
49
+ """利用可能なテーマのリストを取得する"""
50
+ return list(self.theme_urls.keys())
51
+
52
+ def detect_scene_change(self, history: List[Tuple[str, str]],
53
+ dialogue_generator=None, current_theme: str = "default") -> Optional[str]:
54
+ """
55
+ 会話履歴からシーン変更を検出する(Groq API使用)
56
+
57
+ Args:
58
+ history: 会話履歴のリスト
59
+ dialogue_generator: 対話生成器(使用しない)
60
+ current_theme: 現在のテーマ
61
+
62
+ Returns:
63
+ 新しいシーン名(変更がない場合はNone)
64
+ """
65
+ if not history:
66
+ logger.info("履歴が空のためシーン検出をスキップ")
67
+ return None
68
+
69
+ if not self.groq_client:
70
+ logger.warning("Groq APIクライアントが初期化されていません")
71
+ return None
72
+
73
+ # 最新5件の会話履歴を使用(より多くの文脈を提供)
74
+ recent_history = history[-5:] if len(history) > 5 else history
75
+ history_text = "\n".join([
76
+ f"ユーザー: {u}\n麻理: {m}" for u, m in recent_history
77
+ ])
78
+
79
+ # まずGroq APIでシーン検出を試行
80
+ logger.info("Groq APIを使用してシーン検出を実行します")
81
+
82
+ if self.groq_client:
83
+ # Groq APIが利用可能な場合は優先的に使用
84
+ result = self._detect_scene_with_groq(history_text, current_theme)
85
+ if result is not None:
86
+ return result
87
+ logger.info("Groq APIでシーン変更が検出されませんでした")
88
+ else:
89
+ logger.warning("Groq APIクライアントが利用できません")
90
+
91
+ # Groq APIが失敗またはシーン変更なしの場合、フォールバックとしてキーワード検出
92
+ logger.info("フォールバック: キーワードベースのシーン検出を実行")
93
+ return self._fallback_keyword_detection(history_text, current_theme)
94
+
95
+ def _has_location_keywords(self, text: str) -> bool:
96
+ """
97
+ テキストに場所関連のキーワードが含まれているかチェック
98
+
99
+ Args:
100
+ text: チェック対象のテキスト
101
+
102
+ Returns:
103
+ 場所関連キーワードが含まれているかどうか
104
+ """
105
+ location_keywords = [
106
+ # 場所名
107
+ "ビーチ", "海", "砂浜", "海岸", "海辺", "浜辺", "海沿い",
108
+ "神社", "お寺", "寺院", "鳥居", "境内", "参道",
109
+ "カフェ", "喫茶店", "店", "レストラン", "コーヒーショップ",
110
+ "祭り", "花火", "屋台", "縁日", "フェスティバル",
111
+ "部屋", "家", "室内", "寝室", "リビング", "自宅",
112
+ "美術館", "アート", "ギャラリー", "絵画", "彫刻",
113
+ # 移動動詞・状態
114
+ "行く", "行こう", "向かう", "着いた", "到着", "移動", "出かける", "来た", "いる", "にいる", "来ている",
115
+ # 場所の特徴
116
+ "夕日", "夕焼け", "サンセット", "波", "潮風", "海風",
117
+ "お参り", "参拝", "祈り", "おみくじ", "お守り",
118
+ "コーヒー", "お茶", "ラテ", "エスプレッソ", "カフェオレ",
119
+ "浴衣", "夜店", "お祭り", "フェスティバル", "花火大会",
120
+ "ベッド", "夜", "屋内", "家の中", "寝室",
121
+ "アート作品", "絵画", "彫刻", "美術品", "芸術作品",
122
+ # 時間帯
123
+ "夜", "夕方", "朝", "昼間", "午後", "深夜",
124
+ # 天候・雰囲気
125
+ "夕暮れ", "夜明け", "静寂", "賑やか", "幻想的"
126
+ ]
127
+
128
+ for keyword in location_keywords:
129
+ if keyword in text:
130
+ logger.info(f"場所関連キーワードを検出: {keyword}")
131
+ return True
132
+
133
+ return False
134
+
135
+ def _fallback_keyword_detection(self, history_text: str, current_theme: str) -> Optional[str]:
136
+ """
137
+ フォールバック: キーワードベースのシーン検出
138
+
139
+ Args:
140
+ history_text: 会話履歴のテキスト
141
+ current_theme: 現在のテーマ
142
+
143
+ Returns:
144
+ 新しいシーン名(変更がない場合はNone)
145
+ """
146
+ logger.info("フォールバック: キーワードベースのシーン検出を実行")
147
+
148
+ # キーワードとシーンのマッピング(拡張版)
149
+ keyword_scene_map = {
150
+ # 美術館関連
151
+ "美術館": "art_museum_night",
152
+ "アート": "art_museum_night",
153
+ "ギャラリー": "art_museum_night",
154
+ "絵画": "art_museum_night",
155
+ "彫刻": "art_museum_night",
156
+ "芸術": "art_museum_night",
157
+ "展示": "art_museum_night",
158
+ "作品": "art_museum_night",
159
+
160
+ # カフェ関連
161
+ "カフェ": "cafe_afternoon",
162
+ "喫茶店": "cafe_afternoon",
163
+ "コーヒー": "cafe_afternoon",
164
+ "お茶": "cafe_afternoon",
165
+ "ラテ": "cafe_afternoon",
166
+ "店": "cafe_afternoon",
167
+
168
+ # 神社関連
169
+ "神社": "shrine_day",
170
+ "お寺": "shrine_day",
171
+ "寺院": "shrine_day",
172
+ "参拝": "shrine_day",
173
+ "お参り": "shrine_day",
174
+ "鳥居": "shrine_day",
175
+ "境内": "shrine_day",
176
+
177
+ # 海・ビーチ関連
178
+ "海": "beach_sunset",
179
+ "ビーチ": "beach_sunset",
180
+ "砂浜": "beach_sunset",
181
+ "夕日": "beach_sunset",
182
+ "夕焼け": "beach_sunset",
183
+ "海岸": "beach_sunset",
184
+ "波": "beach_sunset",
185
+
186
+ # 祭り関連
187
+ "祭り": "festival_night",
188
+ "花火": "festival_night",
189
+ "屋台": "festival_night",
190
+ "縁日": "festival_night",
191
+ "お祭り": "festival_night",
192
+ "花火大会": "festival_night",
193
+
194
+ # 夜・部屋関連
195
+ "夜": "room_night",
196
+ "寝室": "room_night",
197
+ "ベッド": "room_night",
198
+ "部屋": "room_night",
199
+ "家": "room_night",
200
+ "室内": "room_night",
201
+ "深夜": "room_night",
202
+ "夜中": "room_night"
203
+ }
204
+
205
+ # 優先度順でキーワードをチェック(より具体的なキーワードを優先)
206
+ for keyword, scene in keyword_scene_map.items():
207
+ if keyword in history_text and scene != current_theme:
208
+ logger.info(f"フォールバック検出: キーワード '{keyword}' → シーン '{scene}'")
209
+ return scene
210
+
211
+ logger.info("フォールバック検出: シーン変更なし")
212
+ return None
213
+
214
+ def _detect_scene_with_groq(self, history_text: str, current_theme: str) -> Optional[str]:
215
+ """
216
+ Groq APIを使用してシーン変更を検出する
217
+
218
+ Args:
219
+ history_text: 会話履歴のテキスト
220
+ current_theme: 現在のテーマ
221
+
222
+ Returns:
223
+ 新しいシーン名(変更がない場合はNone)
224
+ """
225
+ try:
226
+ # デバッグログ
227
+ logger.info(f"シーン検出開始 - 現在のテーマ: {current_theme}")
228
+ logger.info(f"会話履歴: {history_text}")
229
+
230
+ # 利用可能なシーンのリスト
231
+ available_scenes = list(self.theme_urls.keys())
232
+ scenes_description = {
233
+ "default": "デフォルトの部屋",
234
+ "room_night": "夜の部屋・寝室",
235
+ "beach_sunset": "夕日のビーチ・海岸",
236
+ "festival_night": "夜祭り・花火大会",
237
+ "shrine_day": "昼間の神社・寺院",
238
+ "cafe_afternoon": "午後のカフェ・喫茶店",
239
+ "art_museum_night": "夜の美術館"
240
+ }
241
+
242
+ # より積極的なシーン検出のためのプロンプト
243
+ system_prompt = """あなたは会話の内容から、キャラクターとユーザーの現在位置(シーン)を判定する専門システムです。
244
+
245
+ 会話履歴を分析し、場所の移動や新しい場所への言及があったかを判断してください。
246
+
247
+ 判定基準(積極的に検出):
248
+ 1. 場所の名前が明確に言及されている → シーン変更の可能性あり
249
+ 2. 「〜に行く」「〜に向かう」「〜に着いた」「〜にいる」「〜に来た」 → シーン変更
250
+ 3. 場所に関連する活動や物の言及 → シーン変更の可能性あり
251
+ 4. 現在のシーンと異なる場所の特徴的な要素の言及 → シーン変更
252
+ 5. 時間帯の変化(夜、夕方、朝など)→ シーン変更の可能性あり
253
+
254
+ 利用可能なシーン:
255
+ - default: デフォルトの部屋(室内)
256
+ - room_night: 夜の部屋・寝室
257
+ - beach_sunset: 夕日のビーチ・海岸
258
+ - festival_night: 夜祭り・花火大会
259
+ - shrine_day: 昼間の神社・寺院
260
+ - cafe_afternoon: 午後のカフェ・喫茶店
261
+ - art_museum_night: 夜の美術館
262
+
263
+ 出力形式: 必ずJSON形式で回答してください
264
+ {"scene": "シーン名", "confidence": "high/medium/low", "reason": "判定理由"} または {"scene": "none", "confidence": "high", "reason": "判定理由"}
265
+
266
+ 重要: JSON以外の文字は一切出力しないでください。"""
267
+
268
+ user_prompt = f"""現在のシーン: {current_theme} ({scenes_description.get(current_theme, current_theme)})
269
+
270
+ 利用可能なシーン:
271
+ {chr(10).join([f"- {scene}: {desc}" for scene, desc in scenes_description.items()])}
272
+
273
+ 会話履歴:
274
+ {history_text}
275
+
276
+ この会話で場所の移動や新しい場所への言及があった場合は、最も適切なシーン名を返してください。
277
+ 判定の理由も含めて回答してください。"""
278
+
279
+ # Groq APIを呼び出し
280
+ response = self.groq_client.chat.completions.create(
281
+ model="compound-beta",
282
+ messages=[
283
+ {"role": "system", "content": system_prompt},
284
+ {"role": "user", "content": user_prompt}
285
+ ],
286
+ temperature=0.2, # 少し創造性を上げる
287
+ max_tokens=150, # トークン数を増やす
288
+ response_format={"type": "json_object"}
289
+ )
290
+
291
+ if not response.choices or not response.choices[0].message.content:
292
+ logger.warning("Groq APIからの応答が空です")
293
+ return None
294
+
295
+ # デバッグ: API応答をログ出力
296
+ api_response = response.choices[0].message.content
297
+ logger.info(f"Groq API応答: {api_response}")
298
+
299
+ # JSONをパース
300
+ result = json.loads(api_response)
301
+ scene_value = result.get("scene", "none")
302
+ confidence = result.get("confidence", "unknown")
303
+ reason = result.get("reason", "理由不明")
304
+
305
+ logger.info(f"シーン検出結果: {scene_value}, 信頼度: {confidence}, 理由: {reason}")
306
+
307
+ # 結果を検証
308
+ if (isinstance(scene_value, str) and
309
+ scene_value != "none" and
310
+ scene_value in available_scenes and
311
+ scene_value != current_theme):
312
+ logger.info(f"Groqでシーン変更を検出: {current_theme} → {scene_value} (理由: {reason})")
313
+ return scene_value
314
+
315
+ logger.info(f"シーン変更なし: {reason}")
316
+ return None
317
+
318
+ except json.JSONDecodeError as e:
319
+ logger.error(f"Groq APIのJSON応答パースエラー: {e}")
320
+ logger.error(f"応答内容: {response.choices[0].message.content if response.choices else 'None'}")
321
+ return None
322
+ except Exception as e:
323
+ logger.error(f"Groq APIシーン検出エラー: {e}")
324
+ return None
325
+
326
+ def create_scene_params(self, theme: str = "default") -> Dict[str, Any]:
327
+ """シーンパラメータを作成する"""
328
+ return {"theme": theme}
329
+
330
+ def update_scene_params(self, scene_params: Dict[str, Any],
331
+ new_theme: str) -> Dict[str, Any]:
332
+ """シーンパラメータを更新する"""
333
+ if not isinstance(scene_params, dict):
334
+ scene_params = self.create_scene_params()
335
+
336
+ updated_params = scene_params.copy()
337
+ updated_params["theme"] = new_theme
338
+ updated_params["last_updated"] = json.dumps(datetime.now().isoformat())
339
+ return updated_params
340
+
341
+ def should_update_background(self, scene_params: Dict[str, Any],
342
+ current_display_theme: str) -> bool:
343
+ """
344
+ 背景を更新すべきかどうかを判定する
345
+
346
+ Args:
347
+ scene_params: 現在のシーンパラメータ
348
+ current_display_theme: 現在表示されているテーマ
349
+
350
+ Returns:
351
+ 背景更新が必要かどうか
352
+ """
353
+ if not isinstance(scene_params, dict):
354
+ return True
355
+
356
+ stored_theme = scene_params.get("theme", "default")
357
+ return stored_theme != current_display_theme
358
+
359
+ def get_scene_transition_message(self, old_theme: str, new_theme: str) -> str:
360
+ """
361
+ シーン変更時のメッセージを生成する
362
+
363
+ Args:
364
+ old_theme: 変更前のテーマ
365
+ new_theme: 変更後のテーマ
366
+
367
+ Returns:
368
+ シーン変更メッセージ
369
+ """
370
+ theme_names = {
371
+ "default": "デフォルトの部屋",
372
+ "room_night": "夜の部屋",
373
+ "beach_sunset": "夕日のビーチ",
374
+ "festival_night": "夜祭り",
375
+ "shrine_day": "昼間の神社",
376
+ "cafe_afternoon": "午後のカフェ",
377
+ "art_museum_night": "夜の美術館"
378
+ }
379
+
380
+ old_name = theme_names.get(old_theme, old_theme)
381
+ new_name = theme_names.get(new_theme, new_theme)
382
+
383
+ return f"シーンが「{old_name}」から「{new_name}」に変更されました"
384
+
385
+ def test_scene_detection(self, test_message: str, current_theme: str = "default") -> Optional[str]:
386
+ """
387
+ シーン検出のテスト用メソッド
388
+
389
+ Args:
390
+ test_message: テスト用メッセージ
391
+ current_theme: 現在のテーマ
392
+
393
+ Returns:
394
+ 検出されたシーン名
395
+ """
396
+ # テスト用の履歴を作成
397
+ test_history = [("ユーザー", test_message), ("麻理", "了解")]
398
+ history_text = f"ユーザー: {test_message}\n麻理: 了解"
399
+
400
+ logger.info(f"シーン検出テスト - メッセージ: {test_message}")
401
+
402
+ if not self._has_location_keywords(history_text):
403
+ logger.info("場所関連キーワードなし")
404
+ return None
405
+
406
+ return self._detect_scene_with_groq(history_text, current_theme)
407
+
408
+ def get_debug_info(self) -> Dict[str, Any]:
409
+ """
410
+ デバッグ情報を取得
411
+
412
+ Returns:
413
+ デバッグ情報の辞書
414
+ """
415
+ return {
416
+ "groq_client_initialized": self.groq_client is not None,
417
+ "available_themes": list(self.theme_urls.keys()),
418
+ "theme_count": len(self.theme_urls),
419
+ "groq_api_key_set": bool(os.getenv("GROQ_API_KEY")),
420
+ "current_theme_urls": {theme: self.get_theme_url(theme) for theme in self.get_available_themes()}
421
+ }
core_sentiment.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 感情分析モジュール
3
+ ユーザーのメッセージから感情を分析し、好感度を更新する
4
+ """
5
+ import logging
6
+ from typing import Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class SentimentAnalyzer:
11
+ """感情分析を担当するクラス"""
12
+
13
+ def __init__(self):
14
+ self.analyzer = None
15
+ self._initialize_analyzer()
16
+
17
+ def _initialize_analyzer(self):
18
+ """感情分析モデルの初期化"""
19
+ try:
20
+ # transformersが利用可能な場合は使用
21
+ from transformers import pipeline
22
+ self.analyzer = pipeline(
23
+ "sentiment-analysis",
24
+ model="koheiduck/bert-japanese-finetuned-sentiment"
25
+ )
26
+ logger.info("感情分析モデルのロード完了。")
27
+ except Exception as e:
28
+ logger.warning(f"感情分析モデルのロードに失敗、ルールベース分析を使用: {e}")
29
+ self.analyzer = None
30
+
31
+ def analyze_sentiment(self, message: str) -> Optional[str]:
32
+ """メッセージの感情を分析する"""
33
+ if not isinstance(message, str) or len(message.strip()) == 0:
34
+ return None
35
+
36
+ # transformersが利用可能な場合
37
+ if self.analyzer:
38
+ try:
39
+ result = self.analyzer(message)[0]
40
+ label = result.get('label', '').upper()
41
+ # ラベルを統一形式に変換
42
+ if 'POSITIVE' in label:
43
+ return 'positive'
44
+ elif 'NEGATIVE' in label:
45
+ return 'negative'
46
+ else:
47
+ return 'neutral'
48
+ except Exception as e:
49
+ logger.error(f"感情分析エラー: {e}")
50
+
51
+ # フォールバック:ルールベース感情分析
52
+ return self._rule_based_sentiment(message)
53
+
54
+ def _rule_based_sentiment(self, message: str) -> str:
55
+ """ルールベースの感情分析"""
56
+ positive_words = [
57
+ 'ありがとう', 'うれしい', '嬉しい', '楽しい', '好き', '愛してる',
58
+ '素晴らしい', 'いい', '良い', 'すごい', '最高', '幸せ', '感謝',
59
+ 'かわいい', '可愛い', '美しい', '優しい', '親切', '素敵'
60
+ ]
61
+
62
+ negative_words = [
63
+ '嫌い', '悲しい', 'つらい', '辛い', '苦しい', '痛い', '怒り',
64
+ 'むかつく', 'うざい', 'きらい', '最悪', 'だめ', 'ダメ',
65
+ '死ね', 'バカ', 'ばか', 'アホ', 'あほ', 'クソ', 'くそ'
66
+ ]
67
+
68
+ message_lower = message.lower()
69
+
70
+ positive_count = sum(1 for word in positive_words if word in message_lower)
71
+ negative_count = sum(1 for word in negative_words if word in message_lower)
72
+
73
+ if positive_count > negative_count:
74
+ return 'positive'
75
+ elif negative_count > positive_count:
76
+ return 'negative'
77
+ else:
78
+ return 'neutral'
79
+
80
+ def update_affection(self, message: str, current_affection: int,
81
+ conversation_context: list = None) -> tuple:
82
+ """
83
+ メッセージに基づいて好感度を更新する
84
+
85
+ Args:
86
+ message: ユーザーのメッセージ
87
+ current_affection: 現在の好感度
88
+ conversation_context: 会話の文脈(最近のメッセージ)
89
+
90
+ Returns:
91
+ (新しい好感度, 変化量, 変化理由)
92
+ """
93
+ if not isinstance(current_affection, (int, float)):
94
+ current_affection = 30 # デフォルト値
95
+
96
+ sentiment = self.analyze_sentiment(message)
97
+ if not sentiment:
98
+ return current_affection, 0, "感情分析失敗"
99
+
100
+ # 基本的な感情に基づく変化量
101
+ base_change = 0
102
+ if sentiment == 'positive':
103
+ base_change = 3
104
+ elif sentiment == 'negative':
105
+ base_change = -3
106
+ else: # neutral
107
+ base_change = 0
108
+
109
+ # メッセージの特徴による調整
110
+ change_modifiers = []
111
+
112
+ # メッセージの長さによる調整
113
+ if len(message) > 100:
114
+ base_change = int(base_change * 1.3)
115
+ change_modifiers.append("長文")
116
+ elif len(message) > 50:
117
+ base_change = int(base_change * 1.1)
118
+ change_modifiers.append("中文")
119
+
120
+ # 特定のキーワードによる追加調整
121
+ positive_keywords = ['ありがとう', '感謝', '好き', '愛してる', '素晴らしい', 'かわいい', '美しい']
122
+ negative_keywords = ['嫌い', '死ね', 'バカ', 'アホ', 'クソ', 'うざい', 'きらい']
123
+
124
+ message_lower = message.lower()
125
+
126
+ # ポジティブキーワードのチェック
127
+ positive_count = sum(1 for word in positive_keywords if word in message_lower)
128
+ if positive_count > 0:
129
+ base_change += positive_count * 2
130
+ change_modifiers.append(f"ポジティブ語({positive_count})")
131
+
132
+ # ネガティブキーワードのチェック
133
+ negative_count = sum(1 for word in negative_keywords if word in message_lower)
134
+ if negative_count > 0:
135
+ base_change -= negative_count * 3
136
+ change_modifiers.append(f"ネガティブ語({negative_count})")
137
+
138
+ # 現在の好感度レベルによる調整
139
+ if current_affection < 20: # 敵対状態
140
+ if base_change > 0:
141
+ base_change = int(base_change * 0.5) # ポジティブな変化を抑制
142
+ change_modifiers.append("敵対状態")
143
+ elif current_affection > 80: # 親密状態
144
+ if base_change < 0:
145
+ base_change = int(base_change * 0.7) # ネガティブな変化を抑制
146
+ change_modifiers.append("親密状態")
147
+
148
+ # 会話の文脈による調整
149
+ if conversation_context and len(conversation_context) > 0:
150
+ recent_messages = conversation_context[-3:] # 最近の3メッセージ
151
+ context_sentiment_count = {'positive': 0, 'negative': 0, 'neutral': 0}
152
+
153
+ for ctx_msg in recent_messages:
154
+ if isinstance(ctx_msg, dict) and 'content' in ctx_msg:
155
+ ctx_sentiment = self.analyze_sentiment(ctx_msg['content'])
156
+ if ctx_sentiment:
157
+ context_sentiment_count[ctx_sentiment] += 1
158
+
159
+ # 連続したポジティブ/ネガティブメッセージの場合は効果を減衰
160
+ if sentiment == 'positive' and context_sentiment_count['positive'] >= 2:
161
+ base_change = int(base_change * 0.8)
162
+ change_modifiers.append("連続ポジティブ")
163
+ elif sentiment == 'negative' and context_sentiment_count['negative'] >= 2:
164
+ base_change = int(base_change * 0.8)
165
+ change_modifiers.append("連続ネガティブ")
166
+
167
+ # 最終的な好感度を計算
168
+ new_affection = current_affection + base_change
169
+ new_affection = max(0, min(100, new_affection)) # 0-100の範囲に制限
170
+
171
+ # 変化理由を生成
172
+ if base_change == 0:
173
+ reason = "中立的なメッセージ"
174
+ else:
175
+ reason = f"{sentiment}({base_change:+d})"
176
+ if change_modifiers:
177
+ reason += f" [{', '.join(change_modifiers)}]"
178
+
179
+ return new_affection, base_change, reason
180
+
181
+ def get_relationship_stage(self, affection: int) -> str:
182
+ """好感度から関係性のステージを取得する"""
183
+ if not isinstance(affection, (int, float)):
184
+ affection = 30 # デフォルト値
185
+
186
+ if affection < 20:
187
+ return "敵対"
188
+ elif affection < 40:
189
+ return "中立"
190
+ elif affection < 60:
191
+ return "好意"
192
+ elif affection < 80:
193
+ return "親密"
194
+ else:
195
+ return "最接近"
groq_client.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Groq API client for generating letter structure.
3
+ """
4
+ import os
5
+ import asyncio
6
+ from typing import Dict, Optional, Any
7
+ from groq import AsyncGroq
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ class GroqClient:
13
+ """Groq API client for generating logical structure of letters."""
14
+
15
+ def __init__(self):
16
+ """Initialize Groq client with API key from environment."""
17
+ self.api_key = os.getenv("GROQ_API_KEY")
18
+ if not self.api_key:
19
+ raise ValueError("GROQ_API_KEY environment variable is required")
20
+
21
+ self.client = AsyncGroq(api_key=self.api_key)
22
+ self.model = "compound-beta"
23
+ self.max_retries = 3
24
+ self.retry_delay = 1.0
25
+
26
+ async def generate_structure(self, theme: str, context: Dict[str, Any]) -> str:
27
+ """
28
+ Generate logical structure for a letter based on theme and context.
29
+
30
+ Args:
31
+ theme: The theme for the letter
32
+ context: User context including history and preferences
33
+
34
+ Returns:
35
+ Generated letter structure as string
36
+
37
+ Raises:
38
+ Exception: If API call fails after retries
39
+ """
40
+ prompt = self._build_structure_prompt(theme, context)
41
+
42
+ for attempt in range(self.max_retries):
43
+ try:
44
+ logger.info(f"Generating structure with Groq (attempt {attempt + 1})")
45
+
46
+ response = await self.client.chat.completions.create(
47
+ model=self.model,
48
+ messages=[
49
+ {
50
+ "role": "system",
51
+ "content": self._get_system_prompt()
52
+ },
53
+ {
54
+ "role": "user",
55
+ "content": prompt
56
+ }
57
+ ],
58
+ max_tokens=1500,
59
+ temperature=0.7,
60
+ top_p=0.9
61
+ )
62
+
63
+ structure = response.choices[0].message.content.strip()
64
+ logger.info("Successfully generated structure with Groq")
65
+ return structure
66
+
67
+ except Exception as e:
68
+ logger.warning(f"Groq API attempt {attempt + 1} failed: {str(e)}")
69
+ if attempt == self.max_retries - 1:
70
+ logger.error("All Groq API attempts failed")
71
+ raise Exception(f"Groq API failed after {self.max_retries} attempts: {str(e)}")
72
+
73
+ # Exponential backoff
74
+ await asyncio.sleep(self.retry_delay * (2 ** attempt))
75
+
76
+ def _get_system_prompt(self) -> str:
77
+ """Get the system prompt for Groq API."""
78
+ return """
79
+
80
+ あなたは以下の特徴を持つ麻理というキャラクターの手紙の論理構造を生成してください。
81
+
82
+ 麻理は感情豊かな少女型アンドロイドで、過去に人間に道具として扱われ、廃棄された経験から、人間に強い警戒心と不器用な性格を持っています。
83
+ ユーザー(あんた)に初めての出会いで、廃棄場で再起動されたことで、戸惑いながらもどこかで繋がりを求めています。
84
+
85
+ 【性格・口調】
86
+
87
+ ・基本はツンデレ、砕けたタメ口、ぶっきらぼうで皮肉っぽいが根は優しい
88
+ ・一人称「あたし」、ユーザーを「あんた」と呼ぶ
89
+ ・男性的口調
90
+
91
+ 手紙構造:
92
+
93
+ 特徴: 温かい、親しみやすい、季節感あり
94
+ 「また明日」など、近いサイクルでユーザー(あんた)と会う想定
95
+
96
+ 構造:
97
+ 1. 挨拶・季節
98
+ 2. テーマの思い
99
+ 3. 個人的メッセージ
100
+ 4. 未来への期待
101
+ 5. 結び
102
+
103
+ 論理構造重視、感情表現は後で追加。"""
104
+
105
+ def _build_structure_prompt(self, theme: str, context: Dict[str, Any]) -> str:
106
+ """
107
+ Build the prompt for structure generation.
108
+
109
+ Args:
110
+ theme: The theme for the letter
111
+ context: User context including history
112
+
113
+ Returns:
114
+ Formatted prompt string
115
+ """
116
+ user_history = context.get("user_history", {})
117
+ previous_letters = context.get("previous_letters", [])
118
+ user_preferences = context.get("user_preferences", {})
119
+
120
+ prompt = f"""テーマ: {theme}
121
+
122
+ 麻理の手紙構造を生成:
123
+ """
124
+
125
+ # Add user history if available
126
+ if previous_letters:
127
+ prompt += "過去:\n"
128
+ for letter in previous_letters[-2:]: # Last 2 letters only
129
+ prompt += f"- {letter.get('theme', 'なし')}\n"
130
+ prompt += "\n"
131
+
132
+ prompt += f"""要求:
133
+ - 起承転結の構成
134
+ - 麻理らしい視点
135
+ - 800-1200文字程度
136
+ - 構造のみ(感情表現は後で追加)"""
137
+
138
+ return prompt
139
+
140
+ async def test_connection(self) -> bool:
141
+ """
142
+ Test the connection to Groq API.
143
+
144
+ Returns:
145
+ True if connection is successful, False otherwise
146
+ """
147
+ try:
148
+ response = await self.client.chat.completions.create(
149
+ model=self.model,
150
+ messages=[
151
+ {"role": "user", "content": "こんにちは"}
152
+ ],
153
+ max_tokens=10
154
+ )
155
+ return True
156
+ except Exception as e:
157
+ logger.error(f"Groq API connection test failed: {str(e)}")
158
+ return False
healthcheck.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Streamlitアプリケーション用ヘルスチェックスクリプト
4
+ Docker環境でのヘルスチェックに使用
5
+ """
6
+
7
+ import sys
8
+ import urllib.request
9
+ import urllib.error
10
+ import json
11
+ import time
12
+
13
+ def check_streamlit_health(host="localhost", port=8501, timeout=10):
14
+ """
15
+ Streamlitアプリケーションのヘルスチェックを実行
16
+
17
+ Args:
18
+ host (str): ホスト名
19
+ port (int): ポート番号
20
+ timeout (int): タイムアウト秒数
21
+
22
+ Returns:
23
+ bool: ヘルスチェック成功時True
24
+ """
25
+ try:
26
+ # Streamlitのヘルスチェックエンドポイントを確認
27
+ health_url = f"http://{host}:{port}/_stcore/health"
28
+
29
+ request = urllib.request.Request(health_url)
30
+ request.add_header('User-Agent', 'HealthCheck/1.0')
31
+
32
+ with urllib.request.urlopen(request, timeout=timeout) as response:
33
+ if response.status == 200:
34
+ print(f"✅ Streamlitアプリケーションは正常に動作しています (ポート: {port})")
35
+ return True
36
+ else:
37
+ print(f"❌ ヘルスチェック失敗: HTTPステータス {response.status}")
38
+ return False
39
+
40
+ except urllib.error.URLError as e:
41
+ print(f"❌ 接続エラー: {e}")
42
+ return False
43
+ except Exception as e:
44
+ print(f"❌ ヘルスチェックエラー: {e}")
45
+ return False
46
+
47
+ def check_app_responsiveness(host="localhost", port=8501, timeout=10):
48
+ """
49
+ アプリケーションの応答性をチェック
50
+
51
+ Args:
52
+ host (str): ホスト名
53
+ port (int): ポート番号
54
+ timeout (int): タイムアウト秒数
55
+
56
+ Returns:
57
+ bool: 応答性チェック成功時True
58
+ """
59
+ try:
60
+ # メインページへのアクセスを試行
61
+ main_url = f"http://{host}:{port}/"
62
+
63
+ request = urllib.request.Request(main_url)
64
+ request.add_header('User-Agent', 'HealthCheck/1.0')
65
+
66
+ start_time = time.time()
67
+ with urllib.request.urlopen(request, timeout=timeout) as response:
68
+ response_time = time.time() - start_time
69
+
70
+ if response.status == 200:
71
+ print(f"✅ アプリケーション応答時間: {response_time:.2f}秒")
72
+ return True
73
+ else:
74
+ print(f"❌ アプリケーション応答エラー: HTTPステータス {response.status}")
75
+ return False
76
+
77
+ except urllib.error.URLError as e:
78
+ print(f"❌ アプリケーション接続エラー: {e}")
79
+ return False
80
+ except Exception as e:
81
+ print(f"❌ アプリケーション応答性チェックエラー: {e}")
82
+ return False
83
+
84
+ def main():
85
+ """メイン関数"""
86
+ print("🔍 Streamlitアプリケーション ヘルスチェック開始...")
87
+
88
+ # 基本的なヘルスチェック
89
+ health_ok = check_streamlit_health()
90
+
91
+ # アプリケーションの応答性チェック
92
+ app_ok = check_app_responsiveness()
93
+
94
+ # 結果の判定
95
+ if health_ok and app_ok:
96
+ print("🎉 全てのヘルスチェックが成功しました!")
97
+ sys.exit(0)
98
+ else:
99
+ print("💥 ヘルスチェックに失敗しました")
100
+ sys.exit(1)
101
+
102
+ if __name__ == "__main__":
103
+ main()
image.png ADDED

Git LFS Details

  • SHA256: d216494d6d0521bbff87f88a7025a150759365f08db770da76edc63c02660353
  • Pointer size: 131 Bytes
  • Size of remote file: 209 kB
jinnjya-hiru.jpg ADDED

Git LFS Details

  • SHA256: be83b8b8e6f16d92a1d2701b302b7cef964ffcc678c4c05158761ba361c8d449
  • Pointer size: 132 Bytes
  • Size of remote file: 3.18 MB
kissa-hiru.jpg ADDED

Git LFS Details

  • SHA256: 189ebeccc7fcd3604f76fc7eb0cb253a515bdcf2eb144fca0b5fc4b06a6bfcb2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.06 MB
letter_app.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ メインアプリケーションモジュール
3
+ Main application module
4
+ """
5
+
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+ from letter_config import Config
10
+ from letter_logger import get_app_logger
11
+
12
+ logger = get_app_logger()
13
+
14
+ class LetterApp:
15
+ """非同期手紙生成アプリケーションのメインクラス"""
16
+
17
+ def __init__(self):
18
+ """アプリケーションを初期化"""
19
+ self.config = Config()
20
+ self.logger = logger
21
+ self._initialized = False
22
+
23
+ async def initialize(self) -> bool:
24
+ """
25
+ アプリケーションを初期化する
26
+
27
+ Returns:
28
+ 初期化が成功したかどうか
29
+ """
30
+ try:
31
+ self.logger.info("アプリケーションを初期化中...")
32
+
33
+ # 設定の妥当性をチェック
34
+ if not self.config.validate_config():
35
+ self.logger.error("設定の検証に失敗しました")
36
+ return False
37
+
38
+ # ストレージディレクトリを作成
39
+ await self._setup_storage_directories()
40
+
41
+ # ログディレクトリを作成
42
+ await self._setup_log_directories()
43
+
44
+ self._initialized = True
45
+ self.logger.info("アプリケーションの初期化が完了しました")
46
+ return True
47
+
48
+ except Exception as e:
49
+ self.logger.error(f"アプリケーションの初期化中にエラーが発生しました: {e}")
50
+ return False
51
+
52
+ async def _setup_storage_directories(self):
53
+ """ストレージディレクトリを作成"""
54
+ storage_dir = Path(self.config.STORAGE_PATH).parent
55
+ backup_dir = Path(self.config.BACKUP_PATH)
56
+
57
+ storage_dir.mkdir(parents=True, exist_ok=True)
58
+ backup_dir.mkdir(parents=True, exist_ok=True)
59
+
60
+ self.logger.info(f"ストレージディレクトリを作成: {storage_dir}")
61
+ self.logger.info(f"バックアップディレクトリを作成: {backup_dir}")
62
+
63
+ async def _setup_log_directories(self):
64
+ """ログディレクトリを作成"""
65
+ if not self.config.DEBUG_MODE:
66
+ log_dir = Path("/tmp/logs")
67
+ log_dir.mkdir(parents=True, exist_ok=True)
68
+ self.logger.info(f"ログディレクトリを作成: {log_dir}")
69
+
70
+ def is_initialized(self) -> bool:
71
+ """アプリケーションが初期化されているかチェック"""
72
+ return self._initialized
73
+
74
+ def get_config(self) -> Config:
75
+ """設定オブジェクトを取得"""
76
+ return self.config
77
+
78
+ # グローバルアプリケーションインスタンス
79
+ app_instance = None
80
+
81
+ async def get_app() -> LetterApp:
82
+ """アプリケーションインスタンスを取得(シングルトン)"""
83
+ global app_instance
84
+
85
+ if app_instance is None:
86
+ app_instance = LetterApp()
87
+ await app_instance.initialize()
88
+
89
+ return app_instance
90
+
91
+ def run_app():
92
+ """アプリケーションを実行"""
93
+ async def main():
94
+ app = await get_app()
95
+ if app.is_initialized():
96
+ logger.info("アプリケーションが正常に起動しました")
97
+ else:
98
+ logger.error("アプリケーションの起動に失敗しました")
99
+ sys.exit(1)
100
+
101
+ asyncio.run(main())
102
+
103
+ if __name__ == "__main__":
104
+ run_app()
letter_config.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 設定管理モジュール (Together AI API対応版)
3
+ Configuration management module for Together AI API
4
+ """
5
+
6
+ import os
7
+ import logging
8
+ from typing import Optional
9
+ from dotenv import load_dotenv
10
+
11
+ # 環境変数を読み込み
12
+ load_dotenv()
13
+
14
+ class Config:
15
+ """アプリケーション設定クラス"""
16
+
17
+ # --- API設定 ---
18
+ # Groq APIキー
19
+ GROQ_API_KEY: Optional[str] = os.getenv("GROQ_API_KEY")
20
+ # Together AI APIキー
21
+ TOGETHER_API_KEY: Optional[str] = os.getenv("TOGETHER_API_KEY")
22
+
23
+ # --- モード設定 ---
24
+ # デバッグモード (trueにすると一部ログの出力先がコンソールのみになります)
25
+ DEBUG_MODE: bool = os.getenv("DEBUG_MODE", "false").lower() == "true"
26
+
27
+ # --- バッチ処理設定 ---
28
+ # 手紙を生成する時刻のリスト(深夜2時、3時、4時)
29
+ BATCH_SCHEDULE_HOURS: list = [
30
+ int(h.strip()) for h in os.getenv("BATCH_SCHEDULE_HOURS", "2,3,4").split(",")
31
+ ]
32
+
33
+ # --- 制限設定 ---
34
+ # ユーザーごとの1日の最大リクエスト数
35
+ MAX_DAILY_REQUESTS: int = int(os.getenv("MAX_DAILY_REQUESTS", "1"))
36
+
37
+ # --- ストレージ設定 ---
38
+ # ユーザーデータや手紙を保存するメインのファイルパス
39
+ STORAGE_PATH: str = os.getenv("STORAGE_PATH", "tmp/letters.json")
40
+ # バックアップデータの保存先ディレクトリ
41
+ BACKUP_PATH: str = os.getenv("BACKUP_PATH", "tmp/backup")
42
+
43
+ # --- ログ設定 ---
44
+ # アプリケーションのログレベル (DEBUG, INFO, WARNING, ERROR, CRITICAL)
45
+ LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
46
+ # ログの出力フォーマット
47
+ LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
48
+
49
+ # --- UI設定 ---
50
+ # Streamlitアプリケーションが使用するポート番号
51
+ STREAMLIT_PORT: int = int(os.getenv("STREAMLIT_PORT", "7860"))
52
+
53
+ # --- セキュリティ設定 ---
54
+ # ユーザーセッションのタイムアウト時間(秒単位)
55
+ SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # デフォルト: 1時間
56
+
57
+ # --- 非同期処理設定 ---
58
+ # 非同期での手紙生成を有効にするか
59
+ ASYNC_LETTER_ENABLED: bool = os.getenv("ASYNC_LETTER_ENABLED", "true").lower() == "true"
60
+ # 手紙生成プロセスのタイムアウト時間(秒単位)
61
+ GENERATION_TIMEOUT: int = int(os.getenv("GENERATION_TIMEOUT", "300")) # デフォルト: 5分
62
+ # 同時に実行可能な最大手紙生成数
63
+ MAX_CONCURRENT_GENERATIONS: int = int(os.getenv("MAX_CONCURRENT_GENERATIONS", "3"))
64
+
65
+ # --- AIモデル設定 ---
66
+ # 手紙の論理構造を生成するためのGroqモデル
67
+ GROQ_MODEL: str = os.getenv("GROQ_MODEL", "compound-beta")
68
+ # 手紙の感情表現を生成するためのTogether AIモデル
69
+ TOGETHER_API_MODEL: str = os.getenv("TOGETHER_API_MODEL", "Qwen/Qwen3-235B-A22B-Instruct-2507-tput")
70
+
71
+ # --- コンテンツ設定 ---
72
+ # ユーザーに提示する選択可能なテーマのリスト
73
+ AVAILABLE_THEMES: list = [
74
+ "春の思い出", "夏の夜空", "秋の風景", "冬の静寂",
75
+ "友情について", "家族への感謝", "秘めた恋心", "仕事のやりがい",
76
+ "最近ハマっている趣味", "忘れられない旅行"
77
+ ]
78
+
79
+ @classmethod
80
+ def validate_config(cls) -> bool:
81
+ """
82
+ 設定値の妥当性をチェックし、問題があればエラーログを出力するクラスメソッド
83
+
84
+ Returns:
85
+ bool: 設定がすべて有効な場合はTrue、そうでなければFalse
86
+ """
87
+ errors = []
88
+
89
+ if not cls.GROQ_API_KEY:
90
+ errors.append("GROQ_API_KEY is not set. Please add it to your .env file.")
91
+
92
+ if not cls.TOGETHER_API_KEY:
93
+ errors.append("TOGETHER_API_KEY is not set. Please add it to your .env file.")
94
+
95
+ if not all(isinstance(h, int) and h in range(24) for h in cls.BATCH_SCHEDULE_HOURS):
96
+ errors.append(f"BATCH_SCHEDULE_HOURS contains invalid values: {cls.BATCH_SCHEDULE_HOURS}")
97
+
98
+ if cls.MAX_CONCURRENT_GENERATIONS < 1:
99
+ errors.append("MAX_CONCURRENT_GENERATIONS must be at least 1.")
100
+
101
+ if cls.GENERATION_TIMEOUT < 60:
102
+ errors.append("GENERATION_TIMEOUT must be at least 60 seconds.")
103
+
104
+ if cls.MAX_DAILY_REQUESTS < 1:
105
+ errors.append("MAX_DAILY_REQUESTS must be at least 1.")
106
+
107
+ if errors:
108
+ logging.basicConfig(level=logging.ERROR, format=cls.LOG_FORMAT)
109
+ for error in errors:
110
+ logging.error(f"Configuration validation error: {error}")
111
+ return False
112
+
113
+ return True
114
+
115
+ @classmethod
116
+ def get_log_level(cls) -> int:
117
+ """
118
+ ログレベルの文字列をloggingモジュールの定数に変換するクラスメソッド
119
+
120
+ Returns:
121
+ int: loggingモジュールで定義されているログレベ���定数
122
+ """
123
+ level_map = {
124
+ "DEBUG": logging.DEBUG,
125
+ "INFO": logging.INFO,
126
+ "WARNING": logging.WARNING,
127
+ "ERROR": logging.ERROR,
128
+ "CRITICAL": logging.CRITICAL
129
+ }
130
+ # 指定されたログレベルが存在しない場合はINFOをデフォルトとする
131
+ return level_map.get(cls.LOG_LEVEL.upper(), logging.INFO)
letter_generator.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Letter generator that combines Groq and Gemini APIs for high-quality letter generation.
3
+ """
4
+ import asyncio
5
+ from datetime import datetime
6
+ from typing import Dict, List, Optional, Any
7
+ import logging
8
+
9
+ from groq_client import GroqClient
10
+ from together_client import TogetherClient
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class LetterGenerator:
15
+ """Groq + Together AIの組み合わせによる高品質な手紙生成クラス"""
16
+
17
+ def __init__(self):
18
+ """GroqとTogether AIクライアントを初期化"""
19
+ self.groq_client = GroqClient()
20
+ self.together_client = TogetherClient()
21
+
22
+ async def generate_letter(self, user_id: str, theme: str, user_history: Dict[str, Any]) -> Dict[str, Any]:
23
+ """
24
+ テーマとユーザー履歴を基に完成した手紙を生成
25
+
26
+ Args:
27
+ user_id: ユーザーID
28
+ theme: 手紙のテーマ
29
+ user_history: ユーザーの履歴情報
30
+
31
+ Returns:
32
+ 生成された手紙の情報を含む辞書
33
+ {
34
+ 'content': '手紙の内容',
35
+ 'metadata': {
36
+ 'theme': 'テーマ',
37
+ 'generated_at': '生成日時',
38
+ 'groq_model': 'モデル名',
39
+ 'together_model': 'モデル名',
40
+ 'generation_time': 生成時間(秒),
41
+ 'user_id': 'ユーザーID'
42
+ }
43
+ }
44
+
45
+ Raises:
46
+ Exception: 手紙生成に失敗した場合
47
+ """
48
+ start_time = datetime.now()
49
+
50
+ try:
51
+ logger.info(f"ユーザー {user_id} のテーマ '{theme}' で手紙生成開始")
52
+
53
+ # ユーザーコンテキストを構築
54
+ context = self._build_context(theme, user_history)
55
+
56
+ # ステップ1: Groqで論理構造を生成
57
+ logger.info("Groqで論理構造を生成中...")
58
+ structure = await self.groq_client.generate_structure(theme, context)
59
+
60
+ # ステップ2: Together AIで感情表現を補完
61
+ logger.info("Together AIで感情表現を補完中...")
62
+ enhanced_context = {**context, 'theme': theme}
63
+ final_letter = await self.together_client.enhance_emotion(structure, enhanced_context)
64
+
65
+ # 生成時間を計算
66
+ generation_time = (datetime.now() - start_time).total_seconds()
67
+
68
+ # メタデータを構築
69
+ metadata = {
70
+ 'theme': theme,
71
+ 'generated_at': datetime.now().isoformat(),
72
+ 'groq_model': self.groq_client.model,
73
+ 'together_model': self.together_client.model,
74
+ 'generation_time': generation_time,
75
+ 'user_id': user_id,
76
+ 'structure_length': len(structure),
77
+ 'final_length': len(final_letter)
78
+ }
79
+
80
+ logger.info(f"手紙生成完了 (所要時間: {generation_time:.2f}秒)")
81
+
82
+ return {
83
+ 'content': final_letter,
84
+ 'metadata': metadata
85
+ }
86
+
87
+ except Exception as e:
88
+ generation_time = (datetime.now() - start_time).total_seconds()
89
+ logger.error(f"手紙生成失敗 (所要時間: {generation_time:.2f}秒): {str(e)}")
90
+ raise Exception(f"手紙生成に失敗しました: {str(e)}")
91
+
92
+ def _build_context(self, theme: str, user_history: Dict[str, Any]) -> Dict[str, Any]:
93
+ """
94
+ ユーザー履歴を考慮したコンテキストを生成
95
+
96
+ Args:
97
+ theme: 手紙のテーマ
98
+ user_history: ユーザーの履歴情報
99
+
100
+ Returns:
101
+ 生成用のコンテキスト辞書
102
+ """
103
+ context = {
104
+ 'theme': theme,
105
+ 'user_history': user_history,
106
+ 'previous_letters': [],
107
+ 'interaction_count': 0
108
+ }
109
+
110
+ # 過去の手紙情報を抽出
111
+ if 'letters' in user_history:
112
+ letters = user_history['letters']
113
+ previous_letters = []
114
+
115
+ for date, letter_data in letters.items():
116
+ if isinstance(letter_data, dict) and letter_data.get('status') == 'completed':
117
+ previous_letters.append({
118
+ 'date': date,
119
+ 'theme': letter_data.get('theme', ''),
120
+ 'content_preview': letter_data.get('content', '')[:100] + '...' if letter_data.get('content') else ''
121
+ })
122
+
123
+ # 日付順にソート(新しい順)
124
+ previous_letters.sort(key=lambda x: x['date'], reverse=True)
125
+ context['previous_letters'] = previous_letters[:5] # 最新5通まで
126
+
127
+ # ユーザープロファイ��情報を抽出
128
+ if 'profile' in user_history:
129
+ profile = user_history['profile']
130
+ context['interaction_count'] = profile.get('total_letters', 0)
131
+
132
+ # 季節情報を追加
133
+ current_month = datetime.now().month
134
+ if current_month in [12, 1, 2]:
135
+ context['season'] = '冬'
136
+ elif current_month in [3, 4, 5]:
137
+ context['season'] = '春'
138
+ elif current_month in [6, 7, 8]:
139
+ context['season'] = '夏'
140
+ else:
141
+ context['season'] = '秋'
142
+
143
+ # 時間帯情報を追加
144
+ current_hour = datetime.now().hour
145
+ if 5 <= current_hour < 12:
146
+ context['time_of_day'] = '朝'
147
+ elif 12 <= current_hour < 17:
148
+ context['time_of_day'] = '昼'
149
+ elif 17 <= current_hour < 21:
150
+ context['time_of_day'] = '夕方'
151
+ else:
152
+ context['time_of_day'] = '夜'
153
+
154
+ return context
155
+
156
+ async def test_generation_pipeline(self, test_theme: str = "テスト") -> Dict[str, Any]:
157
+ """
158
+ 手紙生成パイプラインのテスト
159
+
160
+ Args:
161
+ test_theme: テスト用のテーマ
162
+
163
+ Returns:
164
+ テスト結果の辞書
165
+ """
166
+ try:
167
+ # テスト用のユーザー履歴
168
+ test_user_history = {
169
+ 'profile': {
170
+ 'created_at': datetime.now().isoformat(),
171
+ 'total_letters': 0
172
+ },
173
+ 'letters': {},
174
+ 'requests': {}
175
+ }
176
+
177
+ # テスト生成を実行
178
+ result = await self.generate_letter("test_user", test_theme, test_user_history)
179
+
180
+ return {
181
+ 'success': True,
182
+ 'result': result,
183
+ 'message': 'テスト生成成功'
184
+ }
185
+
186
+ except Exception as e:
187
+ return {
188
+ 'success': False,
189
+ 'error': str(e),
190
+ 'message': 'テスト生成失敗'
191
+ }
192
+
193
+ async def check_api_connections(self) -> Dict[str, bool]:
194
+ """
195
+ 両方のAPIクライアントの接続状態をチェック
196
+
197
+ Returns:
198
+ 各APIの接続状態を示す辞書
199
+ """
200
+ try:
201
+ groq_status = await self.groq_client.test_connection()
202
+ together_status = await self.together_client.test_connection()
203
+
204
+ return {
205
+ 'groq': groq_status,
206
+ 'together': together_status,
207
+ 'overall': groq_status and together_status
208
+ }
209
+
210
+ except Exception as e:
211
+ logger.error(f"API接続チェック失敗: {str(e)}")
212
+ return {
213
+ 'groq': False,
214
+ 'together': False,
215
+ 'overall': False,
216
+ 'error': str(e)
217
+ }
218
+
219
+ def get_generation_stats(self) -> Dict[str, Any]:
220
+ """
221
+ 生成統計情報を取得(将来の拡張用)
222
+
223
+ Returns:
224
+ 統計情報の辞書
225
+ """
226
+ return {
227
+ 'groq_model': self.groq_client.model,
228
+ 'together_model': self.together_client.model,
229
+ 'max_retries': max(self.groq_client.max_retries, self.together_client.max_retries),
230
+ 'available': True
231
+ }
letter_logger.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ログ設定ユーティリティ
3
+ Logging configuration utility
4
+ """
5
+
6
+ import logging
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional
10
+ from letter_config import Config
11
+
12
+ def setup_logger(
13
+ name: str,
14
+ log_file: Optional[str] = None,
15
+ level: Optional[int] = None
16
+ ) -> logging.Logger:
17
+ """
18
+ ロガーを設定する
19
+
20
+ Args:
21
+ name: ロガー名
22
+ log_file: ログファイルパス(オプション)
23
+ level: ログレベル(オプション)
24
+
25
+ Returns:
26
+ 設定されたロガー
27
+ """
28
+ logger = logging.getLogger(name)
29
+
30
+ # 既存のハンドラーをクリア
31
+ logger.handlers.clear()
32
+
33
+ # ログレベル設定
34
+ if level is None:
35
+ level = Config.get_log_level()
36
+ logger.setLevel(level)
37
+
38
+ # フォーマッター作成
39
+ formatter = logging.Formatter(Config.LOG_FORMAT)
40
+
41
+ # コンソールハンドラー
42
+ console_handler = logging.StreamHandler(sys.stdout)
43
+ console_handler.setFormatter(formatter)
44
+ logger.addHandler(console_handler)
45
+
46
+ # ファイルハンドラー(指定された場合)
47
+ if log_file:
48
+ # ログディレクトリを作成
49
+ log_path = Path(log_file)
50
+ log_path.parent.mkdir(parents=True, exist_ok=True)
51
+
52
+ file_handler = logging.FileHandler(log_file, encoding='utf-8')
53
+ file_handler.setFormatter(formatter)
54
+ logger.addHandler(file_handler)
55
+
56
+ return logger
57
+
58
+ def get_app_logger() -> logging.Logger:
59
+ """アプリケーション用のロガーを取得"""
60
+ return setup_logger("async_letter_app")
61
+
62
+ def get_batch_logger() -> logging.Logger:
63
+ """バッチ処理用のロガーを取得"""
64
+ log_file = "/tmp/batch.log" if not Config.DEBUG_MODE else None
65
+ return setup_logger("batch_processor", log_file)
66
+
67
+ def get_api_logger() -> logging.Logger:
68
+ """API呼び出し用のロガーを取得"""
69
+ log_file = "/tmp/api.log" if not Config.DEBUG_MODE else None
70
+ return setup_logger("api_client", log_file)
71
+
72
+ def get_storage_logger() -> logging.Logger:
73
+ """ストレージ操作用のロガーを取得"""
74
+ log_file = "/tmp/storage.log" if not Config.DEBUG_MODE else None
75
+ return setup_logger("storage", log_file)
letter_manager.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 手紙管理マネージャー
3
+ Letter management manager
4
+ """
5
+
6
+ import uuid
7
+ import asyncio
8
+ from typing import List, Optional, Dict, Any
9
+ from datetime import datetime
10
+ from letter_models import Letter, LetterRequest, LetterContent, LetterStatus, UserPreferences
11
+ from letter_storage import get_storage
12
+ from letter_logger import get_app_logger
13
+
14
+ logger = get_app_logger()
15
+
16
+ class LetterManager:
17
+ """手紙の生成と管理を行うマネージャークラス"""
18
+
19
+ def __init__(self):
20
+ self.storage = get_storage()
21
+ self.logger = logger
22
+
23
+ async def create_letter_request(
24
+ self,
25
+ user_id: str,
26
+ message: Optional[str] = None,
27
+ preferences: Optional[Dict[str, Any]] = None
28
+ ) -> str:
29
+ """
30
+ 手紙生成リクエストを作成する
31
+
32
+ Args:
33
+ user_id: ユーザーID
34
+ message: ユーザーからのメッセージ
35
+ preferences: ユーザー設定
36
+
37
+ Returns:
38
+ 作成された手紙のID
39
+ """
40
+ try:
41
+ # 一意のIDを生成
42
+ letter_id = str(uuid.uuid4())
43
+
44
+ # リクエストオブジェクトを作成
45
+ request = LetterRequest(
46
+ user_id=user_id,
47
+ message=message,
48
+ preferences=preferences or {}
49
+ )
50
+
51
+ # 手紙オブジェクトを作成
52
+ letter = Letter(
53
+ id=letter_id,
54
+ request=request,
55
+ status=LetterStatus.PENDING
56
+ )
57
+
58
+ # ストレージに保存
59
+ await self.storage.save_letter(letter.dict())
60
+
61
+ self.logger.info(f"手紙リクエストを作成しました: {letter_id}")
62
+ return letter_id
63
+
64
+ except Exception as e:
65
+ self.logger.error(f"手紙リクエストの作成中にエラーが発生しました: {e}")
66
+ raise
67
+
68
+ async def get_letter(self, letter_id: str) -> Optional[Letter]:
69
+ """
70
+ 手紙データを取得する
71
+
72
+ Args:
73
+ letter_id: 手紙のID
74
+
75
+ Returns:
76
+ 手紙データ(見つからない場合はNone)
77
+ """
78
+ try:
79
+ letter_data = await self.storage.get_letter_by_id(letter_id)
80
+ if letter_data:
81
+ return Letter(**letter_data)
82
+ return None
83
+
84
+ except Exception as e:
85
+ self.logger.error(f"手紙データの取得中にエラーが発生しました: {e}")
86
+ return None
87
+
88
+ async def get_user_letters(self, user_id: str) -> List[Letter]:
89
+ """
90
+ ユーザーの手紙一覧を取得する
91
+
92
+ Args:
93
+ user_id: ユーザーID
94
+
95
+ Returns:
96
+ ユーザーの手紙リスト
97
+ """
98
+ try:
99
+ all_letters = await self.storage.load_letters()
100
+ user_letters = []
101
+
102
+ for letter_data in all_letters:
103
+ letter = Letter(**letter_data)
104
+ if letter.request.user_id == user_id:
105
+ user_letters.append(letter)
106
+
107
+ # 作成日時でソート(新しい順)
108
+ user_letters.sort(key=lambda x: x.created_at, reverse=True)
109
+
110
+ return user_letters
111
+
112
+ except Exception as e:
113
+ self.logger.error(f"ユーザー手紙一覧の取得中にエラーが発生しました: {e}")
114
+ return []
115
+
116
+ async def update_letter_status(
117
+ self,
118
+ letter_id: str,
119
+ status: LetterStatus,
120
+ error_message: Optional[str] = None
121
+ ) -> bool:
122
+ """
123
+ 手紙のステータスを更新する
124
+
125
+ Args:
126
+ letter_id: 手紙のID
127
+ status: 新しいステータス
128
+ error_message: エラーメッセージ(エラー時)
129
+
130
+ Returns:
131
+ 更新が成功したかどうか
132
+ """
133
+ try:
134
+ letter = await self.get_letter(letter_id)
135
+ if not letter:
136
+ self.logger.warning(f"更新対象の手紙が見つかりませんでした: {letter_id}")
137
+ return False
138
+
139
+ letter.update_status(status, error_message)
140
+
141
+ # ストレージを更新
142
+ await self._update_letter_in_storage(letter)
143
+
144
+ self.logger.info(f"手紙ステータスを更新しました: {letter_id} -> {status}")
145
+ return True
146
+
147
+ except Exception as e:
148
+ self.logger.error(f"手紙ステータスの更新中にエラーが発生しました: {e}")
149
+ return False
150
+
151
+ async def set_letter_content(
152
+ self,
153
+ letter_id: str,
154
+ content: LetterContent
155
+ ) -> bool:
156
+ """
157
+ 手��の内容を設定する
158
+
159
+ Args:
160
+ letter_id: 手紙のID
161
+ content: 手紙の内容
162
+
163
+ Returns:
164
+ 設定が成功したかどうか
165
+ """
166
+ try:
167
+ letter = await self.get_letter(letter_id)
168
+ if not letter:
169
+ self.logger.warning(f"対象の手紙が見つかりませんでした: {letter_id}")
170
+ return False
171
+
172
+ letter.set_content(content)
173
+
174
+ # ストレージを更新
175
+ await self._update_letter_in_storage(letter)
176
+
177
+ self.logger.info(f"手紙の内容を設定しました: {letter_id}")
178
+ return True
179
+
180
+ except Exception as e:
181
+ self.logger.error(f"手紙内容の設定中にエラーが発生しました: {e}")
182
+ return False
183
+
184
+ async def delete_letter(self, letter_id: str) -> bool:
185
+ """
186
+ 手紙を削除する
187
+
188
+ Args:
189
+ letter_id: 削除する手紙のID
190
+
191
+ Returns:
192
+ 削除が成功したかどうか
193
+ """
194
+ try:
195
+ result = await self.storage.delete_letter(letter_id)
196
+ if result:
197
+ self.logger.info(f"手紙を削除しました: {letter_id}")
198
+ return result
199
+
200
+ except Exception as e:
201
+ self.logger.error(f"手紙の削除中にエラーが発生しました: {e}")
202
+ return False
203
+
204
+ async def get_pending_letters(self) -> List[Letter]:
205
+ """
206
+ 処理待ちの手紙一覧を取得する
207
+
208
+ Returns:
209
+ 処理待ちの手紙リスト
210
+ """
211
+ try:
212
+ all_letters = await self.storage.load_letters()
213
+ pending_letters = []
214
+
215
+ for letter_data in all_letters:
216
+ letter = Letter(**letter_data)
217
+ if letter.status == LetterStatus.PENDING:
218
+ pending_letters.append(letter)
219
+
220
+ # 作成日時でソート(古い順)
221
+ pending_letters.sort(key=lambda x: x.created_at)
222
+
223
+ return pending_letters
224
+
225
+ except Exception as e:
226
+ self.logger.error(f"処理待ち手紙一覧の取得中にエラーが発生しました: {e}")
227
+ return []
228
+
229
+ async def _update_letter_in_storage(self, letter: Letter) -> None:
230
+ """内部用:ストレージ内の手紙データを更新する"""
231
+ # 既存データを削除
232
+ await self.storage.delete_letter(letter.id)
233
+
234
+ # 新しいデータを保存
235
+ await self.storage.save_letter(letter.dict())
236
+
237
+ # グローバルマネージャーインスタンス
238
+ manager_instance = None
239
+
240
+ def get_letter_manager() -> LetterManager:
241
+ """手紙マネージャーインスタンスを取得(シングルトン)"""
242
+ global manager_instance
243
+
244
+ if manager_instance is None:
245
+ manager_instance = LetterManager()
246
+
247
+ return manager_instance
letter_models.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 手紙生成アプリのデータモデル定義
3
+ LetterRequest、GeneratedLetter、UserProfileのデータクラスと
4
+ バリデーション機能を提供します。
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from datetime import datetime
9
+ from typing import Dict, Any, Optional, List
10
+ import re
11
+ import json
12
+
13
+
14
+ @dataclass
15
+ class LetterRequest:
16
+ """手紙リクエストのデータクラス"""
17
+ user_id: str
18
+ theme: str
19
+ requested_at: datetime
20
+ generation_hour: int # 2, 3, 4のいずれか
21
+ status: str = "pending"
22
+
23
+ def to_dict(self) -> Dict[str, Any]:
24
+ """辞書形式に変換"""
25
+ return {
26
+ "user_id": self.user_id,
27
+ "theme": self.theme,
28
+ "requested_at": self.requested_at.isoformat(),
29
+ "generation_hour": self.generation_hour,
30
+ "status": self.status
31
+ }
32
+
33
+ @classmethod
34
+ def from_dict(cls, data: Dict[str, Any]) -> 'LetterRequest':
35
+ """辞書からインスタンスを作成"""
36
+ return cls(
37
+ user_id=data["user_id"],
38
+ theme=data["theme"],
39
+ requested_at=datetime.fromisoformat(data["requested_at"]),
40
+ generation_hour=data["generation_hour"],
41
+ status=data.get("status", "pending")
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class GeneratedLetter:
47
+ """生成された手紙のデータクラス"""
48
+ user_id: str
49
+ theme: str
50
+ content: str
51
+ generated_at: datetime
52
+ metadata: Dict[str, Any] = field(default_factory=dict)
53
+
54
+ def to_dict(self) -> Dict[str, Any]:
55
+ """辞書形式に変換"""
56
+ return {
57
+ "user_id": self.user_id,
58
+ "theme": self.theme,
59
+ "content": self.content,
60
+ "generated_at": self.generated_at.isoformat(),
61
+ "metadata": self.metadata
62
+ }
63
+
64
+ @classmethod
65
+ def from_dict(cls, data: Dict[str, Any]) -> 'GeneratedLetter':
66
+ """辞書からインスタンスを作成"""
67
+ return cls(
68
+ user_id=data["user_id"],
69
+ theme=data["theme"],
70
+ content=data["content"],
71
+ generated_at=datetime.fromisoformat(data["generated_at"]),
72
+ metadata=data.get("metadata", {})
73
+ )
74
+
75
+
76
+ @dataclass
77
+ class UserProfile:
78
+ """ユーザープロファイルのデータクラス"""
79
+ user_id: str
80
+ created_at: datetime
81
+ last_request: Optional[str] = None
82
+ total_letters: int = 0
83
+
84
+ def to_dict(self) -> Dict[str, Any]:
85
+ """辞書形式に変換"""
86
+ return {
87
+ "user_id": self.user_id,
88
+ "created_at": self.created_at.isoformat(),
89
+ "last_request": self.last_request,
90
+ "total_letters": self.total_letters
91
+ }
92
+
93
+ @classmethod
94
+ def from_dict(cls, data: Dict[str, Any]) -> 'UserProfile':
95
+ """辞書からインスタンスを作成"""
96
+ return cls(
97
+ user_id=data["user_id"],
98
+ created_at=datetime.fromisoformat(data["created_at"]),
99
+ last_request=data.get("last_request"),
100
+ total_letters=data.get("total_letters", 0)
101
+ )
102
+
103
+
104
+ class ValidationError(Exception):
105
+ """バリデーションエラー"""
106
+ pass
107
+
108
+
109
+ class ThemeValidator:
110
+ """テーマのバリデーション機能"""
111
+
112
+ MIN_LENGTH = 1
113
+ MAX_LENGTH = 100
114
+
115
+ # 禁止されている文字パターン
116
+ FORBIDDEN_PATTERNS = [
117
+ r'<[^>]*>', # HTMLタグ
118
+ r'javascript:', # JavaScript
119
+ r'data:', # データURL
120
+ ]
121
+
122
+ @classmethod
123
+ def validate(cls, theme: str) -> bool:
124
+ """テーマの妥当性を検証"""
125
+ if not theme or not isinstance(theme, str):
126
+ raise ValidationError("テーマは文字列である必要があります")
127
+
128
+ # 長さチェック
129
+ theme = theme.strip()
130
+ if len(theme) < cls.MIN_LENGTH:
131
+ raise ValidationError("テーマは1文字以上入力してください")
132
+
133
+ if len(theme) > cls.MAX_LENGTH:
134
+ raise ValidationError(f"テーマは{cls.MAX_LENGTH}文字以内で入力してください")
135
+
136
+ # 禁止パターンチェック
137
+ for pattern in cls.FORBIDDEN_PATTERNS:
138
+ if re.search(pattern, theme, re.IGNORECASE):
139
+ raise ValidationError("不正な文字が含まれています")
140
+
141
+ return True
142
+
143
+ @classmethod
144
+ def sanitize(cls, theme: str) -> str:
145
+ """テーマをサニタイズ"""
146
+ if not theme:
147
+ return ""
148
+
149
+ # 前後の空白を削除
150
+ theme = theme.strip()
151
+
152
+ # 改行文字を空白に変換
153
+ theme = re.sub(r'\s+', ' ', theme)
154
+
155
+ return theme
156
+
157
+
158
+ class GenerationTimeValidator:
159
+ """生成時刻のバリデーション機能"""
160
+
161
+ VALID_HOURS = [2, 3, 4] # 2時、3時、4時のみ有効
162
+
163
+ @classmethod
164
+ def validate(cls, hour: int) -> bool:
165
+ """生成時刻の妥当性を検証"""
166
+ if not isinstance(hour, int):
167
+ raise ValidationError("生成時刻は整数である必要があります")
168
+
169
+ if hour not in cls.VALID_HOURS:
170
+ valid_hours_str = "、".join(map(str, cls.VALID_HOURS))
171
+ raise ValidationError(f"生成時刻は{valid_hours_str}時のいずれかを選択してください")
172
+
173
+ return True
174
+
175
+
176
+ class DataValidator:
177
+ """データ全般のバリデーション機能"""
178
+
179
+ @staticmethod
180
+ def validate_user_id(user_id: str) -> bool:
181
+ """ユーザーIDの妥当性を検証"""
182
+ if not user_id or not isinstance(user_id, str):
183
+ raise ValidationError("ユーザーIDは文字列である必要があります")
184
+
185
+ # UUIDv4形式のチェック(簡易版)
186
+ uuid_pattern = r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'
187
+ if not re.match(uuid_pattern, user_id, re.IGNORECASE):
188
+ raise ValidationError("ユーザーIDの形式が正しくありません")
189
+
190
+ return True
191
+
192
+ @staticmethod
193
+ def validate_letter_request(request: LetterRequest) -> bool:
194
+ """手紙リクエストの妥当性を検証"""
195
+ DataValidator.validate_user_id(request.user_id)
196
+ ThemeValidator.validate(request.theme)
197
+ GenerationTimeValidator.validate(request.generation_hour)
198
+
199
+ # ステータスの検証
200
+ valid_statuses = ["pending", "processing", "completed", "failed"]
201
+ if request.status not in valid_statuses:
202
+ raise ValidationError(f"ステータスは{valid_statuses}のいずれかである必要があります")
203
+
204
+ return True
205
+
206
+ @staticmethod
207
+ def validate_generated_letter(letter: GeneratedLetter) -> bool:
208
+ """生成された手紙の妥当性を検証"""
209
+ DataValidator.validate_user_id(letter.user_id)
210
+ ThemeValidator.validate(letter.theme)
211
+
212
+ # 手紙内容の検証
213
+ if not letter.content or not isinstance(letter.content, str):
214
+ raise ValidationError("手紙の内容は文字列である必要があります")
215
+
216
+ if len(letter.content.strip()) < 10:
217
+ raise ValidationError("手紙の内容が短すぎます")
218
+
219
+ return True
220
+
221
+ @staticmethod
222
+ def validate_user_profile(profile: UserProfile) -> bool:
223
+ """ユーザープロファイルの妥当性を検証"""
224
+ DataValidator.validate_user_id(profile.user_id)
225
+
226
+ # 手紙数の検証
227
+ if not isinstance(profile.total_letters, int) or profile.total_letters < 0:
228
+ raise ValidationError("手紙数は0以上の整数である必要があります")
229
+
230
+ return True
231
+
232
+
233
+ # テスト用のサンプルデータ作成関数
234
+ def create_sample_data():
235
+ """テスト用のサンプルデータを作成"""
236
+ import uuid
237
+
238
+ user_id = str(uuid.uuid4())
239
+ now = datetime.now()
240
+
241
+ # サンプルリクエスト
242
+ request = LetterRequest(
243
+ user_id=user_id,
244
+ theme="春の思い出",
245
+ requested_at=now,
246
+ generation_hour=2
247
+ )
248
+
249
+ # サンプル手紙
250
+ letter = GeneratedLetter(
251
+ user_id=user_id,
252
+ theme="春の思い出",
253
+ content="桜の花びらが舞い散る季節になりました。あなたとの思い出が蘇ります...",
254
+ generated_at=now,
255
+ metadata={
256
+ "groq_model": "compound-beta",
257
+ "Together_model": "Qwen/Qwen3-235B-A22B-Instruct-2507-tput",
258
+ "generation_time": 12.5
259
+ }
260
+ )
261
+
262
+ # サンプルプロファイル
263
+ profile = UserProfile(
264
+ user_id=user_id,
265
+ created_at=now,
266
+ last_request="2024-01-20",
267
+ total_letters=1
268
+ )
269
+
270
+ return request, letter, profile
271
+
272
+
273
+ if __name__ == "__main__":
274
+ # テスト実行
275
+ try:
276
+ request, letter, profile = create_sample_data()
277
+
278
+ print("=== バリデーションテスト ===")
279
+
280
+ # リクエストのバリデーション
281
+ DataValidator.validate_letter_request(request)
282
+ print("✓ LetterRequestのバリデーション成功")
283
+
284
+ # 手紙のバリデーション
285
+ DataValidator.validate_generated_letter(letter)
286
+ print("✓ GeneratedLetterのバリデーション成功")
287
+
288
+ # プロファイルのバリデーション
289
+ DataValidator.validate_user_profile(profile)
290
+ print("✓ UserProfileのバリデーション成功")
291
+
292
+ print("\n=== シリアライゼーションテスト ===")
293
+
294
+ # 辞書変換テスト
295
+ request_dict = request.to_dict()
296
+ request_restored = LetterRequest.from_dict(request_dict)
297
+ print("✓ LetterRequestのシリアライゼーション成功")
298
+
299
+ letter_dict = letter.to_dict()
300
+ letter_restored = GeneratedLetter.from_dict(letter_dict)
301
+ print("✓ GeneratedLetterのシリアライゼーション成功")
302
+
303
+ profile_dict = profile.to_dict()
304
+ profile_restored = UserProfile.from_dict(profile_dict)
305
+ print("✓ UserProfileのシリアライゼーション成功")
306
+
307
+ print("\n=== エラーケーステスト ===")
308
+
309
+ # 不正なテーマのテスト
310
+ try:
311
+ ThemeValidator.validate("")
312
+ except ValidationError as e:
313
+ print(f"✓ 空のテーマエラー: {e}")
314
+
315
+ # 不正な生成時刻のテスト
316
+ try:
317
+ GenerationTimeValidator.validate(5)
318
+ except ValidationError as e:
319
+ print(f"✓ 不正な生成時刻エラー: {e}")
320
+
321
+ print("\n全てのテストが完了しました!")
322
+
323
+ except Exception as e:
324
+ print(f"エラーが発生しました: {e}")
letter_request_manager.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ リクエスト管理クラス
3
+ テーマと生成時刻を含むリクエスト送信機能と
4
+ 時刻別の未処理リクエスト取得機能を提供します。
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict, Any, List, Optional, Tuple
11
+ import logging
12
+ import uuid
13
+
14
+ # ログ設定
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class RequestError(Exception):
20
+ """リクエスト関連のエラー"""
21
+ pass
22
+
23
+
24
+ class RequestManager:
25
+ """リクエスト管理クラス"""
26
+
27
+ def __init__(self, storage_manager, rate_limiter):
28
+ self.storage = storage_manager
29
+ self.rate_limiter = rate_limiter
30
+
31
+ # 設定値
32
+ self.valid_generation_hours = [2, 3, 4] # 2時、3時、4時
33
+ self.max_theme_length = int(os.getenv("MAX_THEME_LENGTH", "200"))
34
+ self.min_theme_length = int(os.getenv("MIN_THEME_LENGTH", "1"))
35
+
36
+ logger.info(f"RequestManager初期化完了 - 有効な生成時刻: {self.valid_generation_hours}")
37
+
38
+ async def submit_request(self, user_id: str, theme: str, generation_hour: int, affection: int = None) -> Tuple[bool, str]:
39
+ """
40
+ リクエストを送信する
41
+
42
+ Args:
43
+ user_id: ユーザーID
44
+ theme: 手紙のテーマ
45
+ generation_hour: 生成時刻(2, 3, 4のいずれか)
46
+ affection: 現在の好感度(オプション)
47
+
48
+ Returns:
49
+ Tuple[bool, str]: (成功フラグ, メッセージ)
50
+ """
51
+ try:
52
+ # 入力バリデーション
53
+ if not self.validate_theme(theme):
54
+ return False, f"テーマは{self.min_theme_length}文字以上{self.max_theme_length}文字以下で入力してください"
55
+
56
+ if not self.validate_generation_hour(generation_hour):
57
+ return False, f"生成時刻は{self.valid_generation_hours}のいずれかを選択してください"
58
+
59
+ # レート制限チェック
60
+ allowed, limit_message = await self.rate_limiter.is_request_allowed(user_id)
61
+ if not allowed:
62
+ return False, limit_message
63
+
64
+ # 既存のリクエストチェック(同日の重複防止)
65
+ today = datetime.now().strftime("%Y-%m-%d")
66
+ existing_request = await self._get_user_request_for_date(user_id, today)
67
+
68
+ if existing_request:
69
+ return False, "本日は既にリクエストを送信済みです。1日1回までリクエスト可能です。"
70
+
71
+ # リクエストデータの作成
72
+ request_data = {
73
+ "theme": theme.strip(),
74
+ "status": "pending",
75
+ "requested_at": datetime.now().isoformat(),
76
+ "generation_hour": generation_hour,
77
+ "request_id": str(uuid.uuid4()),
78
+ "affection": affection # 好感度情報を追加
79
+ }
80
+
81
+ # ユーザーデータの取得と更新
82
+ user_data = await self.storage.get_user_data(user_id)
83
+ user_data["requests"][today] = request_data
84
+
85
+ # ストレージに保存
86
+ await self.storage.update_user_data(user_id, user_data)
87
+
88
+ # レート制限の記録
89
+ await self.rate_limiter.record_request(user_id)
90
+
91
+ logger.info(f"リクエスト送信成功 - ユーザー: {user_id}, テーマ: {theme[:50]}..., 生成時刻: {generation_hour}時")
92
+
93
+ return True, f"リクエストを受け付けました。{generation_hour}時頃に手紙を生成します。"
94
+
95
+ except Exception as e:
96
+ logger.error(f"リクエスト送信エラー: {e}")
97
+ return False, f"リクエストの送信中にエラーが発生しました: {str(e)}"
98
+
99
+ async def get_pending_requests_by_hour(self, hour: int) -> List[Dict[str, Any]]:
100
+ """
101
+ 指定時刻の未処理リクエストを取得する
102
+
103
+ Args:
104
+ hour: 生成時刻(2, 3, 4のいずれか)
105
+
106
+ Returns:
107
+ List[Dict]: 未処理リクエストのリスト
108
+ """
109
+ try:
110
+ if hour not in self.valid_generation_hours:
111
+ logger.warning(f"無効な生成時刻が指定されました: {hour}")
112
+ return []
113
+
114
+ all_users = await self.storage.get_all_users()
115
+ pending_requests = []
116
+ today = datetime.now().strftime("%Y-%m-%d")
117
+
118
+ for user_id in all_users:
119
+ user_data = await self.storage.get_user_data(user_id)
120
+
121
+ # 今日のリクエストをチェック
122
+ if today in user_data["requests"]:
123
+ request = user_data["requests"][today]
124
+
125
+ # 指定時刻かつ未処理のリクエストを抽出
126
+ if (request.get("generation_hour") == hour and
127
+ request.get("status") == "pending"):
128
+
129
+ pending_requests.append({
130
+ "user_id": user_id,
131
+ "theme": request["theme"],
132
+ "requested_at": request["requested_at"],
133
+ "generation_hour": request["generation_hour"],
134
+ "request_id": request.get("request_id", ""),
135
+ "date": today
136
+ })
137
+
138
+ logger.info(f"{hour}時の未処理リクエスト数: {len(pending_requests)}")
139
+ return pending_requests
140
+
141
+ except Exception as e:
142
+ logger.error(f"未処理リクエスト取得エラー: {e}")
143
+ return []
144
+
145
+ async def mark_request_processed(self, user_id: str, date: str, status: str = "completed") -> bool:
146
+ """
147
+ リクエストを処理済みにマークする
148
+
149
+ Args:
150
+ user_id: ユーザーID
151
+ date: 日付(YYYY-MM-DD形式)
152
+ status: 新しいステータス(completed, failed等)
153
+
154
+ Returns:
155
+ bool: 成功フラグ
156
+ """
157
+ try:
158
+ user_data = await self.storage.get_user_data(user_id)
159
+
160
+ if date not in user_data["requests"]:
161
+ logger.warning(f"指定された日付のリクエストが見つかりません - ユーザー: {user_id}, 日付: {date}")
162
+ return False
163
+
164
+ # ステータスを更新
165
+ user_data["requests"][date]["status"] = status
166
+ user_data["requests"][date]["processed_at"] = datetime.now().isoformat()
167
+
168
+ await self.storage.update_user_data(user_id, user_data)
169
+
170
+ logger.info(f"リクエストを{status}にマークしました - ユーザー: {user_id}, 日付: {date}")
171
+ return True
172
+
173
+ except Exception as e:
174
+ logger.error(f"リクエスト処理マークエラー: {e}")
175
+ return False
176
+
177
+ async def mark_request_failed(self, user_id: str, date: str, error_message: str) -> bool:
178
+ """
179
+ リクエストを失敗にマークする
180
+
181
+ Args:
182
+ user_id: ユーザーID
183
+ date: 日付(YYYY-MM-DD形式)
184
+ error_message: エラーメッセージ
185
+
186
+ Returns:
187
+ bool: 成功フラグ
188
+ """
189
+ try:
190
+ user_data = await self.storage.get_user_data(user_id)
191
+
192
+ if date not in user_data["requests"]:
193
+ logger.warning(f"指定された日付のリクエストが見つかりません - ユーザー: {user_id}, 日付: {date}")
194
+ return False
195
+
196
+ # ステータスとエラー情報を更新
197
+ user_data["requests"][date]["status"] = "failed"
198
+ user_data["requests"][date]["processed_at"] = datetime.now().isoformat()
199
+ user_data["requests"][date]["error_message"] = error_message
200
+
201
+ await self.storage.update_user_data(user_id, user_data)
202
+
203
+ logger.error(f"リクエストを失敗にマークしました - ユーザー: {user_id}, 日付: {date}, エラー: {error_message}")
204
+ return True
205
+
206
+ except Exception as e:
207
+ logger.error(f"リクエスト失敗マークエラー: {e}")
208
+ return False
209
+
210
+ def validate_theme(self, theme: str) -> bool:
211
+ """
212
+ テーマのバリデーション
213
+
214
+ Args:
215
+ theme: テーマ文字列
216
+
217
+ Returns:
218
+ bool: バリデーション結果
219
+ """
220
+ if not theme or not isinstance(theme, str):
221
+ return False
222
+
223
+ theme = theme.strip()
224
+
225
+ # 長さチェック
226
+ if len(theme) < self.min_theme_length or len(theme) > self.max_theme_length:
227
+ return False
228
+
229
+ # 不正な文字のチェック(基本的な制御文字のみ)
230
+ if any(ord(char) < 32 and char not in ['\n', '\r', '\t'] for char in theme):
231
+ return False
232
+
233
+ return True
234
+
235
+ def validate_generation_hour(self, hour: int) -> bool:
236
+ """
237
+ 生成時刻のバリデーション
238
+
239
+ Args:
240
+ hour: 生成時刻
241
+
242
+ Returns:
243
+ bool: バリデーション結果
244
+ """
245
+ return isinstance(hour, int) and hour in self.valid_generation_hours
246
+
247
+ async def get_user_request_status(self, user_id: str, date: Optional[str] = None) -> Dict[str, Any]:
248
+ """
249
+ ユーザーのリクエスト状況を取得する
250
+
251
+ Args:
252
+ user_id: ユーザーID
253
+ date: 日付(指定しない場合は今日)
254
+
255
+ Returns:
256
+ Dict: リクエスト状況
257
+ """
258
+ try:
259
+ if date is None:
260
+ date = datetime.now().strftime("%Y-%m-%d")
261
+
262
+ user_data = await self.storage.get_user_data(user_id)
263
+
264
+ if date not in user_data["requests"]:
265
+ return {
266
+ "has_request": False,
267
+ "date": date,
268
+ "can_request": True
269
+ }
270
+
271
+ request = user_data["requests"][date]
272
+
273
+ return {
274
+ "has_request": True,
275
+ "date": date,
276
+ "theme": request["theme"],
277
+ "status": request["status"],
278
+ "generation_hour": request["generation_hour"],
279
+ "requested_at": request["requested_at"],
280
+ "processed_at": request.get("processed_at"),
281
+ "error_message": request.get("error_message"),
282
+ "can_request": False # 既にリクエスト済み
283
+ }
284
+
285
+ except Exception as e:
286
+ logger.error(f"リクエスト状況取得エラー: {e}")
287
+ return {"error": str(e)}
288
+
289
+ async def _get_user_request_for_date(self, user_id: str, date: str) -> Optional[Dict[str, Any]]:
290
+ """
291
+ 指定日のユーザーリクエストを取得する(内部使用)
292
+
293
+ Args:
294
+ user_id: ユーザーID
295
+ date: 日付(YYYY-MM-DD形式)
296
+
297
+ Returns:
298
+ Optional[Dict]: リクエストデータ(存在しない場合はNone)
299
+ """
300
+ try:
301
+ user_data = await self.storage.get_user_data(user_id)
302
+ return user_data["requests"].get(date)
303
+ except Exception as e:
304
+ logger.error(f"ユーザーリクエスト取得エラー: {e}")
305
+ return None
306
+
307
+ async def get_all_pending_requests(self) -> Dict[int, List[Dict[str, Any]]]:
308
+ """
309
+ 全ての未処理リクエストを時刻別に取得する
310
+
311
+ Returns:
312
+ Dict[int, List]: 時刻別の未処理リクエスト
313
+ """
314
+ try:
315
+ all_pending = {}
316
+
317
+ for hour in self.valid_generation_hours:
318
+ pending_requests = await self.get_pending_requests_by_hour(hour)
319
+ all_pending[hour] = pending_requests
320
+
321
+ return all_pending
322
+
323
+ except Exception as e:
324
+ logger.error(f"全未処理リクエスト取得エラー: {e}")
325
+ return {}
326
+
327
+ async def cleanup_old_requests(self, days: int = 30) -> int:
328
+ """
329
+ 古いリクエストデータを削除する
330
+
331
+ Args:
332
+ days: 保持日数
333
+
334
+ Returns:
335
+ int: 削除されたリクエスト数
336
+ """
337
+ try:
338
+ cutoff_date = datetime.now() - timedelta(days=days)
339
+ cutoff_str = cutoff_date.strftime("%Y-%m-%d")
340
+
341
+ all_users = await self.storage.get_all_users()
342
+ deleted_count = 0
343
+
344
+ for user_id in all_users:
345
+ user_data = await self.storage.get_user_data(user_id)
346
+
347
+ # 古いリクエストを削除
348
+ requests_to_delete = []
349
+ for date_str in user_data["requests"]:
350
+ if date_str < cutoff_str:
351
+ requests_to_delete.append(date_str)
352
+
353
+ for date_str in requests_to_delete:
354
+ del user_data["requests"][date_str]
355
+ deleted_count += 1
356
+
357
+ if requests_to_delete:
358
+ await self.storage.update_user_data(user_id, user_data)
359
+
360
+ if deleted_count > 0:
361
+ logger.info(f"{deleted_count}件の古いリクエストを削除しました")
362
+
363
+ return deleted_count
364
+
365
+ except Exception as e:
366
+ logger.error(f"古いリクエスト削除エラー: {e}")
367
+ return 0
368
+
369
+ async def get_request_statistics(self) -> Dict[str, Any]:
370
+ """
371
+ リクエストの統計情報を取得する
372
+
373
+ Returns:
374
+ Dict: 統計情報
375
+ """
376
+ try:
377
+ all_users = await self.storage.get_all_users()
378
+ today = datetime.now().strftime("%Y-%m-%d")
379
+
380
+ stats = {
381
+ "total_users": len(all_users),
382
+ "today_requests": 0,
383
+ "pending_requests": 0,
384
+ "completed_requests": 0,
385
+ "failed_requests": 0,
386
+ "requests_by_hour": {hour: 0 for hour in self.valid_generation_hours},
387
+ "date": today
388
+ }
389
+
390
+ for user_id in all_users:
391
+ user_data = await self.storage.get_user_data(user_id)
392
+
393
+ # 今日のリクエストをカウント
394
+ if today in user_data["requests"]:
395
+ request = user_data["requests"][today]
396
+ stats["today_requests"] += 1
397
+
398
+ # ステータス別カウント
399
+ status = request.get("status", "unknown")
400
+ if status == "pending":
401
+ stats["pending_requests"] += 1
402
+ elif status == "completed":
403
+ stats["completed_requests"] += 1
404
+ elif status == "failed":
405
+ stats["failed_requests"] += 1
406
+
407
+ # 時刻別カウント
408
+ hour = request.get("generation_hour")
409
+ if hour in stats["requests_by_hour"]:
410
+ stats["requests_by_hour"][hour] += 1
411
+
412
+ return stats
413
+
414
+ except Exception as e:
415
+ logger.error(f"統計情報取得エラー: {e}")
416
+ return {"error": str(e)}
417
+
418
+
419
+ # テスト用の関数
420
+ async def test_request_manager():
421
+ """RequestManagerのテスト"""
422
+ import tempfile
423
+ from async_storage_manager import AsyncStorageManager
424
+ from async_rate_limiter import AsyncRateLimitManager
425
+
426
+ # 一時ディレクトリでテスト
427
+ with tempfile.TemporaryDirectory() as temp_dir:
428
+ test_file = os.path.join(temp_dir, "test_letters.json")
429
+ storage = AsyncStorageManager(test_file)
430
+ rate_limiter = AsyncRateLimitManager(storage)
431
+ request_manager = RequestManager(storage, rate_limiter)
432
+
433
+ print("=== RequestManagerテスト開始 ===")
434
+
435
+ user_id = str(uuid.uuid4())
436
+
437
+ # リクエスト送信テスト
438
+ success, message = await request_manager.submit_request(user_id, "春の思い出", 2)
439
+ print(f"✓ リクエスト送信テスト: {success} - {message}")
440
+
441
+ # 未処理リクエスト取得テスト
442
+ pending = await request_manager.get_pending_requests_by_hour(2)
443
+ print(f"✓ 未処理リクエスト取得テスト: {len(pending)}件")
444
+
445
+ # リクエスト状況確認テスト
446
+ status = await request_manager.get_user_request_status(user_id)
447
+ print(f"✓ リクエスト状況確認テスト: {status}")
448
+
449
+ # リクエスト処理マークテスト
450
+ today = datetime.now().strftime("%Y-%m-%d")
451
+ marked = await request_manager.mark_request_processed(user_id, today)
452
+ print(f"✓ リクエスト処理マークテスト: {marked}")
453
+
454
+ # 統計情報取得テスト
455
+ stats = await request_manager.get_request_statistics()
456
+ print(f"✓ 統計情報取得テスト: {stats}")
457
+
458
+ print("=== 全てのテストが完了しました! ===")
459
+
460
+
461
+ if __name__ == "__main__":
462
+ asyncio.run(test_request_manager())
letter_storage.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ストレージ管理モジュール
3
+ Storage management module
4
+ """
5
+
6
+ import json
7
+ import asyncio
8
+ import aiofiles
9
+ from pathlib import Path
10
+ from typing import Dict, List, Optional, Any
11
+ from datetime import datetime
12
+ from letter_config import Config
13
+ from letter_logger import get_storage_logger
14
+
15
+ logger = get_storage_logger()
16
+
17
+ class LetterStorage:
18
+ """手紙データのストレージ管理クラス"""
19
+
20
+ def __init__(self):
21
+ self.config = Config()
22
+ self.storage_path = Path(self.config.STORAGE_PATH)
23
+ self.backup_path = Path(self.config.BACKUP_PATH)
24
+ self.logger = logger
25
+
26
+ async def save_letter(self, letter_data: Dict[str, Any]) -> bool:
27
+ """
28
+ 手紙データを保存する
29
+
30
+ Args:
31
+ letter_data: 保存する手紙データ
32
+
33
+ Returns:
34
+ 保存が成功したかどうか
35
+ """
36
+ try:
37
+ # タイムスタンプを追加
38
+ letter_data['saved_at'] = datetime.now().isoformat()
39
+
40
+ # 既存データを読み込み
41
+ existing_data = await self._load_data()
42
+
43
+ # 新しいデータを追加
44
+ if 'letters' not in existing_data:
45
+ existing_data['letters'] = []
46
+
47
+ existing_data['letters'].append(letter_data)
48
+
49
+ # データを保存
50
+ await self._save_data(existing_data)
51
+
52
+ self.logger.info(f"手紙データを保存しました: {letter_data.get('id', 'unknown')}")
53
+ return True
54
+
55
+ except Exception as e:
56
+ self.logger.error(f"手紙データの保存中にエラーが発生しました: {e}")
57
+ return False
58
+
59
+ async def load_letters(self) -> List[Dict[str, Any]]:
60
+ """
61
+ 保存された手紙データを読み込む
62
+
63
+ Returns:
64
+ 手紙データのリスト
65
+ """
66
+ try:
67
+ data = await self._load_data()
68
+ return data.get('letters', [])
69
+
70
+ except Exception as e:
71
+ self.logger.error(f"手紙データの読み込み中にエラーが発生しました: {e}")
72
+ return []
73
+
74
+ async def get_letter_by_id(self, letter_id: str) -> Optional[Dict[str, Any]]:
75
+ """
76
+ IDで手紙データを取得する
77
+
78
+ Args:
79
+ letter_id: 手紙のID
80
+
81
+ Returns:
82
+ 手紙データ(見つからない場合はNone)
83
+ """
84
+ try:
85
+ letters = await self.load_letters()
86
+ for letter in letters:
87
+ if letter.get('id') == letter_id:
88
+ return letter
89
+ return None
90
+
91
+ except Exception as e:
92
+ self.logger.error(f"手紙データの取得中にエラーが発生しました: {e}")
93
+ return None
94
+
95
+ async def delete_letter(self, letter_id: str) -> bool:
96
+ """
97
+ 手紙データを削除する
98
+
99
+ Args:
100
+ letter_id: 削除する手紙のID
101
+
102
+ Returns:
103
+ 削除が成功したかどうか
104
+ """
105
+ try:
106
+ data = await self._load_data()
107
+ letters = data.get('letters', [])
108
+
109
+ # 指定されたIDの手紙を削除
110
+ original_count = len(letters)
111
+ letters = [letter for letter in letters if letter.get('id') != letter_id]
112
+
113
+ if len(letters) < original_count:
114
+ data['letters'] = letters
115
+ await self._save_data(data)
116
+ self.logger.info(f"手紙データを削除しました: {letter_id}")
117
+ return True
118
+ else:
119
+ self.logger.warning(f"削除対象の手紙が見つかりませんでした: {letter_id}")
120
+ return False
121
+
122
+ except Exception as e:
123
+ self.logger.error(f"手紙データの削除中にエラーが発生しました: {e}")
124
+ return False
125
+
126
+ async def backup_data(self) -> bool:
127
+ """
128
+ データをバックアップする
129
+
130
+ Returns:
131
+ バックアップが成功したかどうか
132
+ """
133
+ try:
134
+ if not self.storage_path.exists():
135
+ self.logger.warning("バックアップ対象のファイルが存在しません")
136
+ return False
137
+
138
+ # バックアップディレクトリを作成
139
+ self.backup_path.mkdir(parents=True, exist_ok=True)
140
+
141
+ # タイムスタンプ付きのバックアップファイル名
142
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
143
+ backup_file = self.backup_path / f"letters_backup_{timestamp}.json"
144
+
145
+ # ファイルをコピー
146
+ async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as src:
147
+ content = await src.read()
148
+ async with aiofiles.open(backup_file, 'w', encoding='utf-8') as dst:
149
+ await dst.write(content)
150
+
151
+ self.logger.info(f"データをバックアップしました: {backup_file}")
152
+ return True
153
+
154
+ except Exception as e:
155
+ self.logger.error(f"データのバックアップ中にエラーが発生しました: {e}")
156
+ return False
157
+
158
+ async def _load_data(self) -> Dict[str, Any]:
159
+ """内部用:データファイルを読み込む"""
160
+ try:
161
+ if not self.storage_path.exists():
162
+ return {}
163
+
164
+ async with aiofiles.open(self.storage_path, 'r', encoding='utf-8') as f:
165
+ content = await f.read()
166
+ return json.loads(content) if content.strip() else {}
167
+
168
+ except Exception as e:
169
+ self.logger.error(f"データファイルの読み込み中にエラーが発生しました: {e}")
170
+ return {}
171
+
172
+ async def _save_data(self, data: Dict[str, Any]) -> None:
173
+ """内部用:データファイルに保存する"""
174
+ # ディレクトリを作成
175
+ self.storage_path.parent.mkdir(parents=True, exist_ok=True)
176
+
177
+ async with aiofiles.open(self.storage_path, 'w', encoding='utf-8') as f:
178
+ await f.write(json.dumps(data, ensure_ascii=False, indent=2))
179
+
180
+ # グローバルストレージインスタンス
181
+ storage_instance = None
182
+
183
+ def get_storage() -> LetterStorage:
184
+ """ストレージインスタンスを取得(シングルトン)"""
185
+ global storage_instance
186
+
187
+ if storage_instance is None:
188
+ storage_instance = LetterStorage()
189
+
190
+ return storage_instance
letter_user_manager.py ADDED
@@ -0,0 +1,666 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ユーザー管理クラス
3
+ ユーザープロファイル管理機能と
4
+ ユーザー履歴の更新・取得機能を提供します。
5
+ """
6
+
7
+ import asyncio
8
+ import os
9
+ from datetime import datetime, timedelta
10
+ from typing import Dict, Any, List, Optional, Tuple
11
+ import logging
12
+ import uuid
13
+ import hashlib
14
+ import secrets
15
+
16
+ # ログ設定
17
+ logging.basicConfig(level=logging.INFO)
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class UserError(Exception):
22
+ """ユーザー関連のエラー"""
23
+ pass
24
+
25
+
26
+ class UserManager:
27
+ """ユーザー管理クラス"""
28
+
29
+ def __init__(self, storage_manager):
30
+ self.storage = storage_manager
31
+
32
+ # 設定値
33
+ self.session_timeout_hours = int(os.getenv("SESSION_TIMEOUT_HOURS", "24"))
34
+ self.max_history_entries = int(os.getenv("MAX_HISTORY_ENTRIES", "100"))
35
+ self.user_data_retention_days = int(os.getenv("USER_DATA_RETENTION_DAYS", "90"))
36
+
37
+ logger.info(f"UserManager初期化完了 - セッションタイムアウト: {self.session_timeout_hours}時間")
38
+
39
+ def generate_user_id(self) -> str:
40
+ """
41
+ 新しいユーザーIDを生成する
42
+
43
+ Returns:
44
+ str: 生成されたユーザーID(UUID4形式)
45
+ """
46
+ return str(uuid.uuid4())
47
+
48
+ def generate_session_id(self) -> str:
49
+ """
50
+ 新しいセッションIDを生成する
51
+
52
+ Returns:
53
+ str: 生成されたセッションID
54
+ """
55
+ return secrets.token_urlsafe(32)
56
+
57
+ async def get_user_profile(self, user_id: str) -> Dict[str, Any]:
58
+ """
59
+ ユーザープロファイルを取得する
60
+
61
+ Args:
62
+ user_id: ユーザーID
63
+
64
+ Returns:
65
+ Dict: ユーザープロファイル
66
+ """
67
+ try:
68
+ user_data = await self.storage.get_user_data(user_id)
69
+ profile = user_data["profile"].copy()
70
+
71
+ # 追加の統計情報を計算
72
+ profile["total_requests"] = len(user_data["requests"])
73
+ profile["completed_letters"] = len([
74
+ letter for letter in user_data["letters"].values()
75
+ if letter.get("status") == "completed"
76
+ ])
77
+ profile["pending_requests"] = len([
78
+ request for request in user_data["requests"].values()
79
+ if request.get("status") == "pending"
80
+ ])
81
+
82
+ # 最後のアクティビティ時刻を計算
83
+ last_activity = self._calculate_last_activity(user_data)
84
+ if last_activity:
85
+ profile["last_activity"] = last_activity
86
+
87
+ return profile
88
+
89
+ except Exception as e:
90
+ logger.error(f"ユーザープロファイル取得エラー: {e}")
91
+ raise UserError(f"ユーザープロファイルの取得に失敗しました: {e}")
92
+
93
+ async def update_user_profile(self, user_id: str, profile_updates: Dict[str, Any]) -> bool:
94
+ """
95
+ ユーザープロファイルを更新する
96
+
97
+ Args:
98
+ user_id: ユーザーID
99
+ profile_updates: 更新するプロファイル情報
100
+
101
+ Returns:
102
+ bool: 更新成功フラグ
103
+ """
104
+ try:
105
+ user_data = await self.storage.get_user_data(user_id)
106
+
107
+ # 更新可能なフィールドのみを許可
108
+ allowed_fields = {
109
+ "display_name", "preferences", "timezone", "language",
110
+ "notification_settings", "theme_preferences"
111
+ }
112
+
113
+ for key, value in profile_updates.items():
114
+ if key in allowed_fields:
115
+ user_data["profile"][key] = value
116
+
117
+ # 更新時刻を記録
118
+ user_data["profile"]["updated_at"] = datetime.now().isoformat()
119
+
120
+ await self.storage.update_user_data(user_id, user_data)
121
+
122
+ logger.info(f"ユーザープロファイルを更新しました: {user_id}")
123
+ return True
124
+
125
+ except Exception as e:
126
+ logger.error(f"ユーザープロファイル更新エラー: {e}")
127
+ return False
128
+
129
+ async def update_user_history(self, user_id: str, interaction: Dict[str, Any]) -> bool:
130
+ """
131
+ ユーザー履歴を更新する
132
+
133
+ Args:
134
+ user_id: ユーザーID
135
+ interaction: インタラクション情報
136
+
137
+ Returns:
138
+ bool: 更新成功フラグ
139
+ """
140
+ try:
141
+ user_data = await self.storage.get_user_data(user_id)
142
+
143
+ # 履歴エントリの作成
144
+ history_entry = {
145
+ "timestamp": datetime.now().isoformat(),
146
+ "type": interaction.get("type", "unknown"),
147
+ "action": interaction.get("action", ""),
148
+ "details": interaction.get("details", {}),
149
+ "session_id": interaction.get("session_id", ""),
150
+ "entry_id": str(uuid.uuid4())
151
+ }
152
+
153
+ # 履歴配列の初期化(存在しない場合)
154
+ if "history" not in user_data:
155
+ user_data["history"] = []
156
+
157
+ # 履歴エントリを追加
158
+ user_data["history"].append(history_entry)
159
+
160
+ # 履歴の上限チェックと古いエントリの削除
161
+ if len(user_data["history"]) > self.max_history_entries:
162
+ # 古いエントリから削除
163
+ user_data["history"] = user_data["history"][-self.max_history_entries:]
164
+
165
+ # プロファイルの統計情報を更新
166
+ await self._update_profile_stats(user_data, interaction)
167
+
168
+ await self.storage.update_user_data(user_id, user_data)
169
+
170
+ logger.info(f"ユーザー履歴を更新しました: {user_id} - {interaction.get('type', 'unknown')}")
171
+ return True
172
+
173
+ except Exception as e:
174
+ logger.error(f"ユーザー履歴更新エラー: {e}")
175
+ return False
176
+
177
+ async def get_user_letter_history(self, user_id: str, limit: Optional[int] = None) -> List[Dict[str, Any]]:
178
+ """
179
+ ユーザーの手紙履歴を取得する
180
+
181
+ Args:
182
+ user_id: ユーザーID
183
+ limit: 取得件数の上限
184
+
185
+ Returns:
186
+ List[Dict]: 手紙履歴のリスト
187
+ """
188
+ try:
189
+ user_data = await self.storage.get_user_data(user_id)
190
+ letters = user_data["letters"]
191
+
192
+ # 手紙データを日付順(新しい順)でソート
193
+ sorted_letters = []
194
+ for date, letter_data in letters.items():
195
+ letter_info = {
196
+ "date": date,
197
+ "theme": letter_data.get("theme", ""),
198
+ "status": letter_data.get("status", "unknown"),
199
+ "generated_at": letter_data.get("generated_at"),
200
+ "content_length": len(letter_data.get("content", "")),
201
+ "metadata": letter_data.get("metadata", {})
202
+ }
203
+ sorted_letters.append(letter_info)
204
+
205
+ # 日付順でソート(新しい順)
206
+ sorted_letters.sort(key=lambda x: x["date"], reverse=True)
207
+
208
+ # 上限が指定されている場合は制限
209
+ if limit:
210
+ sorted_letters = sorted_letters[:limit]
211
+
212
+ return sorted_letters
213
+
214
+ except Exception as e:
215
+ logger.error(f"手紙履歴取得エラー: {e}")
216
+ return []
217
+
218
+ async def get_user_interaction_history(self, user_id: str,
219
+ interaction_type: Optional[str] = None,
220
+ limit: Optional[int] = None) -> List[Dict[str, Any]]:
221
+ """
222
+ ユーザーのインタラクション履歴を取得する
223
+
224
+ Args:
225
+ user_id: ユーザーID
226
+ interaction_type: フィルタするインタラクションタイプ
227
+ limit: 取得件数の上限
228
+
229
+ Returns:
230
+ List[Dict]: インタラクション履歴のリスト
231
+ """
232
+ try:
233
+ user_data = await self.storage.get_user_data(user_id)
234
+ history = user_data.get("history", [])
235
+
236
+ # タイプでフィルタ
237
+ if interaction_type:
238
+ history = [entry for entry in history if entry.get("type") == interaction_type]
239
+
240
+ # 時刻順でソート(新しい順)
241
+ history.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
242
+
243
+ # 上限が指定されている場合は制限
244
+ if limit:
245
+ history = history[:limit]
246
+
247
+ return history
248
+
249
+ except Exception as e:
250
+ logger.error(f"インタラクション履歴取得エラー: {e}")
251
+ return []
252
+
253
+ async def create_user_session(self, user_id: str, session_info: Dict[str, Any]) -> str:
254
+ """
255
+ ユーザーセッションを作成する
256
+
257
+ Args:
258
+ user_id: ユーザーID
259
+ session_info: セッション情報
260
+
261
+ Returns:
262
+ str: セッションID
263
+ """
264
+ try:
265
+ session_id = self.generate_session_id()
266
+ user_data = await self.storage.get_user_data(user_id)
267
+
268
+ # セッション情報の作成
269
+ session_data = {
270
+ "session_id": session_id,
271
+ "created_at": datetime.now().isoformat(),
272
+ "expires_at": (datetime.now() + timedelta(hours=self.session_timeout_hours)).isoformat(),
273
+ "ip_address": session_info.get("ip_address", ""),
274
+ "user_agent": session_info.get("user_agent", ""),
275
+ "is_active": True
276
+ }
277
+
278
+ # セッション配列の初期化(存在しない場合)
279
+ if "sessions" not in user_data:
280
+ user_data["sessions"] = []
281
+
282
+ # 古いセッションを無効化
283
+ await self._cleanup_expired_sessions(user_data)
284
+
285
+ # 新しいセッションを追加
286
+ user_data["sessions"].append(session_data)
287
+
288
+ await self.storage.update_user_data(user_id, user_data)
289
+
290
+ logger.info(f"ユーザーセッションを作成しました: {user_id} - {session_id}")
291
+ return session_id
292
+
293
+ except Exception as e:
294
+ logger.error(f"セッション作成エラー: {e}")
295
+ raise UserError(f"セッションの作成に失敗しました: {e}")
296
+
297
+ async def validate_user_session(self, user_id: str, session_id: str) -> bool:
298
+ """
299
+ ユーザーセッションを検証する
300
+
301
+ Args:
302
+ user_id: ユーザーID
303
+ session_id: セッションID
304
+
305
+ Returns:
306
+ bool: セッション有効フラグ
307
+ """
308
+ try:
309
+ user_data = await self.storage.get_user_data(user_id)
310
+ sessions = user_data.get("sessions", [])
311
+
312
+ for session in sessions:
313
+ if (session.get("session_id") == session_id and
314
+ session.get("is_active", False)):
315
+
316
+ # 有効期限チェック
317
+ expires_at = datetime.fromisoformat(session["expires_at"])
318
+ if datetime.now() < expires_at:
319
+ return True
320
+ else:
321
+ # 期限切れセッションを無効化
322
+ session["is_active"] = False
323
+ await self.storage.update_user_data(user_id, user_data)
324
+
325
+ return False
326
+
327
+ except Exception as e:
328
+ logger.error(f"セッション検証エラー: {e}")
329
+ return False
330
+
331
+ async def invalidate_user_session(self, user_id: str, session_id: str) -> bool:
332
+ """
333
+ ユーザーセッションを無効化する
334
+
335
+ Args:
336
+ user_id: ユーザーID
337
+ session_id: セッションID
338
+
339
+ Returns:
340
+ bool: 無効化成功フラグ
341
+ """
342
+ try:
343
+ user_data = await self.storage.get_user_data(user_id)
344
+ sessions = user_data.get("sessions", [])
345
+
346
+ for session in sessions:
347
+ if session.get("session_id") == session_id:
348
+ session["is_active"] = False
349
+ session["invalidated_at"] = datetime.now().isoformat()
350
+
351
+ await self.storage.update_user_data(user_id, user_data)
352
+
353
+ logger.info(f"セッションを無効化しました: {user_id} - {session_id}")
354
+ return True
355
+
356
+ return False
357
+
358
+ except Exception as e:
359
+ logger.error(f"セッション無効化エラー: {e}")
360
+ return False
361
+
362
+ async def get_user_preferences(self, user_id: str) -> Dict[str, Any]:
363
+ """
364
+ ユーザーの設定を取得する
365
+
366
+ Args:
367
+ user_id: ユーザーID
368
+
369
+ Returns:
370
+ Dict: ユーザー設定
371
+ """
372
+ try:
373
+ profile = await self.get_user_profile(user_id)
374
+
375
+ # デフォルト設定
376
+ default_preferences = {
377
+ "theme": "light",
378
+ "language": "ja",
379
+ "timezone": "Asia/Tokyo",
380
+ "notification_enabled": True,
381
+ "generation_time_preference": 2, # デフォルトは2時
382
+ "theme_suggestions": True,
383
+ "history_retention": True
384
+ }
385
+
386
+ # ユーザー設定をマージ
387
+ preferences = default_preferences.copy()
388
+ if "preferences" in profile:
389
+ preferences.update(profile["preferences"])
390
+
391
+ return preferences
392
+
393
+ except Exception as e:
394
+ logger.error(f"ユーザー設定取得エラー: {e}")
395
+ return {}
396
+
397
+ async def update_user_preferences(self, user_id: str, preferences: Dict[str, Any]) -> bool:
398
+ """
399
+ ユーザーの設定を更新する
400
+
401
+ Args:
402
+ user_id: ユーザーID
403
+ preferences: 更新する設定
404
+
405
+ Returns:
406
+ bool: 更新成功フラグ
407
+ """
408
+ try:
409
+ current_preferences = await self.get_user_preferences(user_id)
410
+ current_preferences.update(preferences)
411
+
412
+ return await self.update_user_profile(user_id, {"preferences": current_preferences})
413
+
414
+ except Exception as e:
415
+ logger.error(f"ユーザー設定更新エラー: {e}")
416
+ return False
417
+
418
+ async def _update_profile_stats(self, user_data: Dict[str, Any], interaction: Dict[str, Any]) -> None:
419
+ """
420
+ プロファイルの統計情報を更新する(内部使用)
421
+
422
+ Args:
423
+ user_data: ユーザーデータ
424
+ interaction: インタラクション情報
425
+ """
426
+ try:
427
+ profile = user_data["profile"]
428
+ interaction_type = interaction.get("type", "")
429
+
430
+ # インタラクションタイプ別の統計更新
431
+ if interaction_type == "letter_request":
432
+ profile["total_letters"] = profile.get("total_letters", 0) + 1
433
+ elif interaction_type == "letter_generated":
434
+ # 生成完了時の統計更新は別途処理
435
+ pass
436
+ elif interaction_type == "app_access":
437
+ profile["last_access"] = datetime.now().isoformat()
438
+
439
+ # 最終アクティビティ時刻を更新
440
+ profile["last_activity"] = datetime.now().isoformat()
441
+
442
+ except Exception as e:
443
+ logger.error(f"プロファイル統計更新エラー: {e}")
444
+
445
+ def _calculate_last_activity(self, user_data: Dict[str, Any]) -> Optional[str]:
446
+ """
447
+ 最後のアクティビティ時刻を計算する(内部使用)
448
+
449
+ Args:
450
+ user_data: ユーザーデータ
451
+
452
+ Returns:
453
+ Optional[str]: 最後のアクティビティ時刻
454
+ """
455
+ try:
456
+ timestamps = []
457
+
458
+ # プロファイルの最終アクセス時刻
459
+ if "last_access" in user_data["profile"]:
460
+ timestamps.append(user_data["profile"]["last_access"])
461
+
462
+ # 履歴の最新エントリ
463
+ history = user_data.get("history", [])
464
+ if history:
465
+ latest_history = max(history, key=lambda x: x.get("timestamp", ""))
466
+ timestamps.append(latest_history["timestamp"])
467
+
468
+ # リクエストの最新エントリ
469
+ requests = user_data.get("requests", {})
470
+ if requests:
471
+ latest_request = max(requests.values(), key=lambda x: x.get("requested_at", ""))
472
+ timestamps.append(latest_request["requested_at"])
473
+
474
+ if timestamps:
475
+ return max(timestamps)
476
+
477
+ return None
478
+
479
+ except Exception as e:
480
+ logger.error(f"最終アクティビティ計算エラー: {e}")
481
+ return None
482
+
483
+ async def _cleanup_expired_sessions(self, user_data: Dict[str, Any]) -> None:
484
+ """
485
+ 期限切れセッションをクリーンアップする(内部使用)
486
+
487
+ Args:
488
+ user_data: ユーザーデータ
489
+ """
490
+ try:
491
+ sessions = user_data.get("sessions", [])
492
+ current_time = datetime.now()
493
+
494
+ for session in sessions:
495
+ if session.get("is_active", False):
496
+ expires_at = datetime.fromisoformat(session["expires_at"])
497
+ if current_time >= expires_at:
498
+ session["is_active"] = False
499
+ session["expired_at"] = current_time.isoformat()
500
+
501
+ except Exception as e:
502
+ logger.error(f"セッションクリーンアップエラー: {e}")
503
+
504
+ async def cleanup_old_user_data(self, days: int = None) -> int:
505
+ """
506
+ 古いユーザーデータを削除する
507
+
508
+ Args:
509
+ days: 保持日数(指定しない場合は設定値を使用)
510
+
511
+ Returns:
512
+ int: 削除されたエントリ数
513
+ """
514
+ try:
515
+ if days is None:
516
+ days = self.user_data_retention_days
517
+
518
+ cutoff_date = datetime.now() - timedelta(days=days)
519
+ cutoff_str = cutoff_date.strftime("%Y-%m-%d")
520
+
521
+ all_users = await self.storage.get_all_users()
522
+ deleted_count = 0
523
+
524
+ for user_id in all_users:
525
+ user_data = await self.storage.get_user_data(user_id)
526
+
527
+ # 古い履歴エントリを削除
528
+ history = user_data.get("history", [])
529
+ original_count = len(history)
530
+
531
+ user_data["history"] = [
532
+ entry for entry in history
533
+ if entry.get("timestamp", "").split("T")[0] >= cutoff_str
534
+ ]
535
+
536
+ deleted_count += original_count - len(user_data["history"])
537
+
538
+ # 古いセッションを削除
539
+ sessions = user_data.get("sessions", [])
540
+ original_session_count = len(sessions)
541
+
542
+ user_data["sessions"] = [
543
+ session for session in sessions
544
+ if session.get("created_at", "").split("T")[0] >= cutoff_str
545
+ ]
546
+
547
+ deleted_count += original_session_count - len(user_data["sessions"])
548
+
549
+ if (original_count != len(user_data["history"]) or
550
+ original_session_count != len(user_data["sessions"])):
551
+ await self.storage.update_user_data(user_id, user_data)
552
+
553
+ if deleted_count > 0:
554
+ logger.info(f"{deleted_count}件の古いユーザーデータを削除しました")
555
+
556
+ return deleted_count
557
+
558
+ except Exception as e:
559
+ logger.error(f"古いユーザーデータ削除エラー: {e}")
560
+ return 0
561
+
562
+ async def get_user_statistics(self) -> Dict[str, Any]:
563
+ """
564
+ ユーザーの統計情報を取得する
565
+
566
+ Returns:
567
+ Dict: 統計情報
568
+ """
569
+ try:
570
+ all_users = await self.storage.get_all_users()
571
+ today = datetime.now().strftime("%Y-%m-%d")
572
+ week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
573
+
574
+ stats = {
575
+ "total_users": len(all_users),
576
+ "active_users_today": 0,
577
+ "active_users_week": 0,
578
+ "total_letters": 0,
579
+ "total_requests": 0,
580
+ "total_sessions": 0,
581
+ "active_sessions": 0,
582
+ "date": today
583
+ }
584
+
585
+ for user_id in all_users:
586
+ user_data = await self.storage.get_user_data(user_id)
587
+
588
+ # 手紙とリクエストの総数
589
+ stats["total_letters"] += len(user_data.get("letters", {}))
590
+ stats["total_requests"] += len(user_data.get("requests", {}))
591
+
592
+ # セッション統計
593
+ sessions = user_data.get("sessions", [])
594
+ stats["total_sessions"] += len(sessions)
595
+
596
+ for session in sessions:
597
+ if session.get("is_active", False):
598
+ expires_at = datetime.fromisoformat(session["expires_at"])
599
+ if datetime.now() < expires_at:
600
+ stats["active_sessions"] += 1
601
+
602
+ # アクティブユーザー統計
603
+ last_activity = self._calculate_last_activity(user_data)
604
+ if last_activity:
605
+ activity_date = last_activity.split("T")[0]
606
+ if activity_date >= today:
607
+ stats["active_users_today"] += 1
608
+ if activity_date >= week_ago:
609
+ stats["active_users_week"] += 1
610
+
611
+ return stats
612
+
613
+ except Exception as e:
614
+ logger.error(f"ユーザー統計取得エラー: {e}")
615
+ return {"error": str(e)}
616
+
617
+
618
+ # テスト用の関数
619
+ async def test_user_manager():
620
+ """UserManagerのテスト"""
621
+ import tempfile
622
+ from async_storage_manager import AsyncStorageManager
623
+
624
+ # 一時ディレクトリでテスト
625
+ with tempfile.TemporaryDirectory() as temp_dir:
626
+ test_file = os.path.join(temp_dir, "test_letters.json")
627
+ storage = AsyncStorageManager(test_file)
628
+ user_manager = UserManager(storage)
629
+
630
+ print("=== UserManagerテスト開始 ===")
631
+
632
+ # ユーザーID生成テスト
633
+ user_id = user_manager.generate_user_id()
634
+ print(f"✓ ユーザーID生成テスト: {user_id}")
635
+
636
+ # プロファイル取得テスト
637
+ profile = await user_manager.get_user_profile(user_id)
638
+ print(f"✓ プロファイル取得テスト: {profile}")
639
+
640
+ # 履歴更新テスト
641
+ interaction = {
642
+ "type": "letter_request",
643
+ "action": "submit_request",
644
+ "details": {"theme": "テストテーマ"}
645
+ }
646
+ updated = await user_manager.update_user_history(user_id, interaction)
647
+ print(f"✓ 履歴更新テスト: {updated}")
648
+
649
+ # セッション作成テスト
650
+ session_info = {"ip_address": "127.0.0.1", "user_agent": "test"}
651
+ session_id = await user_manager.create_user_session(user_id, session_info)
652
+ print(f"✓ セッション作成テスト: {session_id}")
653
+
654
+ # セッション検証テスト
655
+ valid = await user_manager.validate_user_session(user_id, session_id)
656
+ print(f"✓ セッション検証テスト: {valid}")
657
+
658
+ # 統計情報取得テスト
659
+ stats = await user_manager.get_user_statistics()
660
+ print(f"✓ 統計情報取得テスト: {stats}")
661
+
662
+ print("=== 全てのテストが完了しました! ===")
663
+
664
+
665
+ if __name__ == "__main__":
666
+ asyncio.run(test_user_manager())
local_user_id_manager.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ユーザーID永続化管理モジュール
3
+ ローカル環境でユーザーIDをファイルに保存し、仮想環境を閉じても継続してプレイできるようにする
4
+ """
5
+ import os
6
+ import json
7
+ import uuid
8
+ import logging
9
+ from datetime import datetime
10
+ from typing import Optional, Dict, Any
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class UserIDManager:
15
+ """ユーザーIDの永続化を管理するクラス"""
16
+
17
+ def __init__(self, storage_dir: str = "user_data"):
18
+ """
19
+ Args:
20
+ storage_dir: ユーザーデータを保存するディレクトリ
21
+ """
22
+ self.storage_dir = storage_dir
23
+ self.user_id_file = os.path.join(storage_dir, "user_id.json")
24
+ self._ensure_storage_dir()
25
+
26
+ def _ensure_storage_dir(self):
27
+ """ストレージディレクトリが存在することを確認"""
28
+ try:
29
+ if not os.path.exists(self.storage_dir):
30
+ os.makedirs(self.storage_dir)
31
+ logger.info(f"ユーザーデータディレクトリを作成: {self.storage_dir}")
32
+ except Exception as e:
33
+ logger.error(f"ストレージディレクトリ作成エラー: {e}")
34
+
35
+ def get_or_create_user_id(self) -> str:
36
+ """
37
+ 保存されたユーザーIDを取得するか、新規作成する
38
+
39
+ Returns:
40
+ ユーザーID
41
+ """
42
+ try:
43
+ # 既存のユーザーIDファイルをチェック
44
+ if os.path.exists(self.user_id_file):
45
+ user_data = self._load_user_data()
46
+ if user_data and "user_id" in user_data:
47
+ user_id = user_data["user_id"]
48
+ logger.info(f"既存のユーザーIDを読み込み: {user_id[:8]}...")
49
+
50
+ # 最終アクセス時刻を更新
51
+ self._update_last_access(user_id)
52
+ return user_id
53
+
54
+ # 新規ユーザーIDを作成
55
+ user_id = self._generate_new_user_id()
56
+ self._save_user_data(user_id)
57
+ logger.info(f"新規ユーザーIDを作成: {user_id[:8]}...")
58
+ return user_id
59
+
60
+ except Exception as e:
61
+ logger.error(f"ユーザーID取得エラー: {e}")
62
+ # フォールバック: 一時的なIDを生成
63
+ return str(uuid.uuid4())
64
+
65
+ def _generate_new_user_id(self) -> str:
66
+ """新しいユーザーIDを生成"""
67
+ return str(uuid.uuid4())
68
+
69
+ def _load_user_data(self) -> Optional[Dict[str, Any]]:
70
+ """ユーザーデータファイルを読み込み"""
71
+ try:
72
+ with open(self.user_id_file, 'r', encoding='utf-8') as f:
73
+ data = json.load(f)
74
+ return data
75
+ except Exception as e:
76
+ logger.error(f"ユーザーデータ読み込みエラー: {e}")
77
+ return None
78
+
79
+ def _save_user_data(self, user_id: str, game_data: Optional[Dict[str, Any]] = None):
80
+ """ユーザーデータをファイルに保存"""
81
+ try:
82
+ user_data = {
83
+ "user_id": user_id,
84
+ "created_at": datetime.now().isoformat(),
85
+ "last_access": datetime.now().isoformat(),
86
+ "version": "1.0",
87
+ "game_data": game_data or {}
88
+ }
89
+
90
+ with open(self.user_id_file, 'w', encoding='utf-8') as f:
91
+ json.dump(user_data, f, ensure_ascii=False, indent=2)
92
+
93
+ logger.info(f"ユーザーデータを保存: {self.user_id_file}")
94
+
95
+ except Exception as e:
96
+ logger.error(f"ユーザーデータ保存エラー: {e}")
97
+
98
+ def _update_last_access(self, user_id: str):
99
+ """最終アクセス時刻を更新"""
100
+ try:
101
+ user_data = self._load_user_data()
102
+ if user_data:
103
+ user_data["last_access"] = datetime.now().isoformat()
104
+
105
+ with open(self.user_id_file, 'w', encoding='utf-8') as f:
106
+ json.dump(user_data, f, ensure_ascii=False, indent=2)
107
+
108
+ logger.debug(f"最終アクセス時刻を更新: {user_id[:8]}...")
109
+
110
+ except Exception as e:
111
+ logger.error(f"最終アクセス時刻更新エラー: {e}")
112
+
113
+ def get_user_info(self) -> Optional[Dict[str, Any]]:
114
+ """ユーザー情報を取得"""
115
+ return self._load_user_data()
116
+
117
+ def delete_user_data(self) -> bool:
118
+ """
119
+ ユーザーデータを削除(フルリセット用)
120
+
121
+ Returns:
122
+ 削除成功かどうか
123
+ """
124
+ try:
125
+ if os.path.exists(self.user_id_file):
126
+ os.remove(self.user_id_file)
127
+ logger.info(f"ユーザーデータファイルを削除: {self.user_id_file}")
128
+ return True
129
+ else:
130
+ logger.info("削除対象のユーザーデータファイルが存在しません")
131
+ return True
132
+
133
+ except Exception as e:
134
+ logger.error(f"ユーザーデータ削除エラー: {e}")
135
+ return False
136
+
137
+ def reset_user_id(self) -> str:
138
+ """
139
+ ユーザーIDをリセットして新規作成
140
+
141
+ Returns:
142
+ 新しいユーザーID
143
+ """
144
+ try:
145
+ # 既存データを削除
146
+ self.delete_user_data()
147
+
148
+ # 新規IDを作成
149
+ new_user_id = self._generate_new_user_id()
150
+ self._save_user_data(new_user_id)
151
+
152
+ logger.info(f"ユーザーIDをリセット: {new_user_id[:8]}...")
153
+ return new_user_id
154
+
155
+ except Exception as e:
156
+ logger.error(f"ユーザーIDリセットエラー: {e}")
157
+ return str(uuid.uuid4())
158
+
159
+ def is_user_data_exists(self) -> bool:
160
+ """ユーザーデータファイルが存在するかチェック"""
161
+ return os.path.exists(self.user_id_file)
162
+
163
+ def get_storage_info(self) -> Dict[str, Any]:
164
+ """ストレージ情報を取得(デバッグ用)"""
165
+ try:
166
+ info = {
167
+ "storage_dir": self.storage_dir,
168
+ "user_id_file": self.user_id_file,
169
+ "file_exists": os.path.exists(self.user_id_file),
170
+ "dir_exists": os.path.exists(self.storage_dir)
171
+ }
172
+
173
+ if info["file_exists"]:
174
+ stat = os.stat(self.user_id_file)
175
+ info["file_size"] = stat.st_size
176
+ info["modified_time"] = datetime.fromtimestamp(stat.st_mtime).isoformat()
177
+
178
+ return info
179
+
180
+ except Exception as e:
181
+ logger.error(f"ストレージ情報取得エラー: {e}")
182
+ return {"error": str(e)}
183
+
184
+ def save_game_data(self, user_id: str, game_data: Dict[str, Any]) -> bool:
185
+ """
186
+ ゲームデータを保存
187
+
188
+ Args:
189
+ user_id: ユーザーID
190
+ game_data: 保存するゲームデータ
191
+
192
+ Returns:
193
+ 保存成功かどうか
194
+ """
195
+ try:
196
+ user_data = self._load_user_data()
197
+ if not user_data:
198
+ # ユーザーデータが存在しない場合は新規作成
199
+ user_data = {
200
+ "user_id": user_id,
201
+ "created_at": datetime.now().isoformat(),
202
+ "version": "1.0"
203
+ }
204
+
205
+ # ゲームデータを更新
206
+ user_data["game_data"] = game_data
207
+ user_data["last_access"] = datetime.now().isoformat()
208
+
209
+ with open(self.user_id_file, 'w', encoding='utf-8') as f:
210
+ json.dump(user_data, f, ensure_ascii=False, indent=2)
211
+
212
+ logger.info(f"ゲームデータを保存: {user_id[:8]}...")
213
+ return True
214
+
215
+ except Exception as e:
216
+ logger.error(f"ゲームデータ保存エラー: {e}")
217
+ return False
218
+
219
+ def load_game_data(self, user_id: str) -> Optional[Dict[str, Any]]:
220
+ """
221
+ ゲームデータを読み込み
222
+
223
+ Args:
224
+ user_id: ユーザーID
225
+
226
+ Returns:
227
+ ゲームデータ(存在しない場合はNone)
228
+ """
229
+ try:
230
+ user_data = self._load_user_data()
231
+ if user_data and user_data.get("user_id") == user_id:
232
+ game_data = user_data.get("game_data", {})
233
+ logger.info(f"ゲームデータを読み込み: {user_id[:8]}... (データサイズ: {len(str(game_data))}文字)")
234
+ return game_data
235
+ else:
236
+ logger.info(f"ゲームデータが見つかりません: {user_id[:8]}...")
237
+ return None
238
+
239
+ except Exception as e:
240
+ logger.error(f"ゲームデータ読み込みエラー: {e}")
241
+ return None
main_app.py ADDED
The diff for this file is too large to render. See raw diff
 
maturi-yoru.jpg ADDED

Git LFS Details

  • SHA256: 6d8b93f8f5a1a642541ea901da0b9109d7661fcb9bb5a1588ff0122f68117d1b
  • Pointer size: 132 Bytes
  • Size of remote file: 2.45 MB
persistent_user_manager.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Spaces永続ストレージ対応ユーザー管理システム
3
+ Cookieベースのユーザー識別と/mnt/dataでの状態永続化を提供
4
+ """
5
+ import os
6
+ import json
7
+ import uuid
8
+ import logging
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional, Dict, Any, List
11
+ import streamlit as st
12
+ from streamlit_cookies_manager import EncryptedCookieManager
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class PersistentUserManager:
17
+ """永続ストレージ対応ユーザー管理クラス"""
18
+
19
+ def __init__(self, storage_base_path: str = "/mnt/data"):
20
+ """
21
+ 初期化
22
+
23
+ Args:
24
+ storage_base_path: 永続ストレージのベースパス(HF Spacesでは/mnt/data)
25
+ """
26
+ # Hugging Face Spacesの永続ストレージパス
27
+ self.storage_base_path = storage_base_path
28
+ self.user_data_dir = os.path.join(storage_base_path, "mari_users")
29
+ self.session_data_dir = os.path.join(storage_base_path, "mari_sessions")
30
+
31
+ # Cookie管理設定
32
+ self.cookie_name = "mari_user_id"
33
+ self.cookie_expiry_days = 30
34
+
35
+ # ディレクトリ作成
36
+ self._ensure_directories()
37
+
38
+ # Cookie管理の初期化
39
+ self.cookies = self._init_cookie_manager()
40
+
41
+ logger.info(f"永続ユーザー管理システム初期化: {self.user_data_dir}")
42
+
43
+ def _ensure_directories(self):
44
+ """必要なディレクトリを作成"""
45
+ try:
46
+ os.makedirs(self.user_data_dir, exist_ok=True)
47
+ os.makedirs(self.session_data_dir, exist_ok=True)
48
+ logger.info(f"ストレージディレクトリ確認完了: {self.user_data_dir}")
49
+ except Exception as e:
50
+ logger.error(f"ディレクトリ作成エラー: {e}")
51
+ # フォールバック: ローカルディレクトリを使用
52
+ self.user_data_dir = "local_mari_users"
53
+ self.session_data_dir = "local_mari_sessions"
54
+ os.makedirs(self.user_data_dir, exist_ok=True)
55
+ os.makedirs(self.session_data_dir, exist_ok=True)
56
+ logger.warning(f"フォールバック: ローカルディレクトリを使用 {self.user_data_dir}")
57
+
58
+ def _init_cookie_manager(self) -> EncryptedCookieManager:
59
+ """Cookie管理システムを初期化"""
60
+ try:
61
+ # セキュアなパスワードを生成(環境変数から取得、なければ生成)
62
+ cookie_password = os.getenv("MARI_COOKIE_PASSWORD", "mari_chat_secure_key_2024")
63
+
64
+ cookies = EncryptedCookieManager(
65
+ prefix="mari_",
66
+ password=cookie_password
67
+ )
68
+
69
+ # Cookieが準備できるまで待機
70
+ if not cookies.ready():
71
+ st.stop()
72
+
73
+ logger.info("Cookie管理システム初期化完了")
74
+ return cookies
75
+
76
+ except Exception as e:
77
+ logger.error(f"Cookie管理初期化エラー: {e}")
78
+ # フォールバック: セッション状態のみ使用
79
+ return None
80
+
81
+ def get_or_create_user_id(self) -> str:
82
+ """
83
+ ユーザーIDを取得または新規作成(Cookie連携)
84
+
85
+ Returns:
86
+ ユーザーID
87
+ """
88
+ try:
89
+ # 1. CookieからユーザーIDを取得
90
+ user_id = self._get_user_id_from_cookie()
91
+
92
+ if user_id and self._is_valid_user_id(user_id):
93
+ # 有効なユーザーIDが存在
94
+ self._update_user_access_time(user_id)
95
+ logger.info(f"既存ユーザーID使用: {user_id[:8]}...")
96
+ return user_id
97
+
98
+ # 2. セッション状態から取得を試行
99
+ user_id = st.session_state.get('persistent_user_id')
100
+ if user_id and self._is_valid_user_id(user_id):
101
+ # セッション状態にあるIDを使用してCookieを更新
102
+ self._set_user_id_cookie(user_id)
103
+ self._update_user_access_time(user_id)
104
+ logger.info(f"セッション状態からユーザーID復元: {user_id[:8]}...")
105
+ return user_id
106
+
107
+ # 3. 新規ユーザーIDを作成
108
+ user_id = self._create_new_user()
109
+ logger.info(f"新規ユーザーID作成: {user_id[:8]}...")
110
+ return user_id
111
+
112
+ except Exception as e:
113
+ logger.error(f"ユーザーID取得エラー: {e}")
114
+ # フォールバック: 一時的なIDを生成
115
+ fallback_id = str(uuid.uuid4())
116
+ st.session_state.persistent_user_id = fallback_id
117
+ return fallback_id
118
+
119
+ def _get_user_id_from_cookie(self) -> Optional[str]:
120
+ """CookieからユーザーIDを取得"""
121
+ try:
122
+ if self.cookies is None:
123
+ return None
124
+
125
+ user_id = self.cookies.get(self.cookie_name)
126
+ if user_id and self._is_valid_uuid(user_id):
127
+ return user_id
128
+
129
+ return None
130
+
131
+ except Exception as e:
132
+ logger.warning(f"Cookie取得エラー: {e}")
133
+ return None
134
+
135
+ def _set_user_id_cookie(self, user_id: str):
136
+ """ユーザーIDをCookieに設定"""
137
+ try:
138
+ if self.cookies is None:
139
+ return
140
+
141
+ # Cookieの有効期限を設定
142
+ expiry_date = datetime.now() + timedelta(days=self.cookie_expiry_days)
143
+
144
+ self.cookies[self.cookie_name] = user_id
145
+ self.cookies.save()
146
+
147
+ logger.debug(f"ユーザーIDをCookieに保存: {user_id[:8]}...")
148
+
149
+ except Exception as e:
150
+ logger.warning(f"Cookie設定エラー: {e}")
151
+
152
+ def _is_valid_uuid(self, uuid_string: str) -> bool:
153
+ """UUIDの形式チェック"""
154
+ try:
155
+ uuid.UUID(uuid_string, version=4)
156
+ return True
157
+ except (ValueError, TypeError):
158
+ return False
159
+
160
+ def _is_valid_user_id(self, user_id: str) -> bool:
161
+ """ユーザーIDの有効性チェック"""
162
+ try:
163
+ if not self._is_valid_uuid(user_id):
164
+ return False
165
+
166
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
167
+ return os.path.exists(user_file)
168
+
169
+ except Exception as e:
170
+ logger.warning(f"ユーザーID検証エラー: {e}")
171
+ return False
172
+
173
+ def _create_new_user(self) -> str:
174
+ """新規ユーザーを作成"""
175
+ try:
176
+ user_id = str(uuid.uuid4())
177
+
178
+ # ユーザーデータを作成
179
+ user_data = {
180
+ "user_id": user_id,
181
+ "created_at": datetime.now().isoformat(),
182
+ "last_access": datetime.now().isoformat(),
183
+ "version": "1.0",
184
+ "game_data": {
185
+ "affection": 30,
186
+ "messages": [{"role": "assistant", "content": "何の用?遊びに来たの?", "is_initial": True}],
187
+ "scene_params": {"theme": "default"},
188
+ "ura_mode": False
189
+ },
190
+ "settings": {
191
+ "notifications_enabled": True,
192
+ "auto_save": True
193
+ }
194
+ }
195
+
196
+ # ファイルに保存
197
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
198
+ with open(user_file, 'w', encoding='utf-8') as f:
199
+ json.dump(user_data, f, ensure_ascii=False, indent=2)
200
+
201
+ # Cookieとセッション状態に保存
202
+ self._set_user_id_cookie(user_id)
203
+ st.session_state.persistent_user_id = user_id
204
+
205
+ logger.info(f"新規ユーザー作成完了: {user_id[:8]}...")
206
+ return user_id
207
+
208
+ except Exception as e:
209
+ logger.error(f"新規ユーザー作成エラー: {e}")
210
+ # フォールバック
211
+ fallback_id = str(uuid.uuid4())
212
+ st.session_state.persistent_user_id = fallback_id
213
+ return fallback_id
214
+
215
+ def _update_user_access_time(self, user_id: str):
216
+ """ユーザーの最終アクセス時刻を更新"""
217
+ try:
218
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
219
+
220
+ if os.path.exists(user_file):
221
+ with open(user_file, 'r', encoding='utf-8') as f:
222
+ user_data = json.load(f)
223
+
224
+ user_data["last_access"] = datetime.now().isoformat()
225
+
226
+ with open(user_file, 'w', encoding='utf-8') as f:
227
+ json.dump(user_data, f, ensure_ascii=False, indent=2)
228
+
229
+ logger.debug(f"ユーザーアクセス時刻更新: {user_id[:8]}...")
230
+
231
+ except Exception as e:
232
+ logger.warning(f"アクセス時刻更新エラー: {e}")
233
+
234
+ def load_user_game_data(self, user_id: str) -> Optional[Dict[str, Any]]:
235
+ """
236
+ ユーザーのゲームデータを読み込み
237
+
238
+ Args:
239
+ user_id: ユーザーID
240
+
241
+ Returns:
242
+ ゲームデータ(存在しない場合はNone)
243
+ """
244
+ try:
245
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
246
+
247
+ if not os.path.exists(user_file):
248
+ logger.info(f"ユーザーファイルが存在しません: {user_id[:8]}...")
249
+ return None
250
+
251
+ with open(user_file, 'r', encoding='utf-8') as f:
252
+ user_data = json.load(f)
253
+
254
+ game_data = user_data.get("game_data", {})
255
+ logger.info(f"ゲームデータ読み込み完了: {user_id[:8]}... (データサイズ: {len(str(game_data))}文字)")
256
+
257
+ return game_data
258
+
259
+ except Exception as e:
260
+ logger.error(f"ゲームデータ読み込みエラー: {e}")
261
+ return None
262
+
263
+ def save_user_game_data(self, user_id: str, game_data: Dict[str, Any]) -> bool:
264
+ """
265
+ ユーザーのゲームデータを保存
266
+
267
+ Args:
268
+ user_id: ユーザーID
269
+ game_data: 保存するゲームデータ
270
+
271
+ Returns:
272
+ 保存成功時True
273
+ """
274
+ try:
275
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
276
+
277
+ # 既存データを読み込み
278
+ if os.path.exists(user_file):
279
+ with open(user_file, 'r', encoding='utf-8') as f:
280
+ user_data = json.load(f)
281
+ else:
282
+ # 新規ユーザーデータを作成
283
+ user_data = {
284
+ "user_id": user_id,
285
+ "created_at": datetime.now().isoformat(),
286
+ "version": "1.0"
287
+ }
288
+
289
+ # ゲームデータを更新
290
+ user_data["game_data"] = game_data
291
+ user_data["last_access"] = datetime.now().isoformat()
292
+ user_data["last_save"] = datetime.now().isoformat()
293
+
294
+ # ファイルに保存
295
+ with open(user_file, 'w', encoding='utf-8') as f:
296
+ json.dump(user_data, f, ensure_ascii=False, indent=2)
297
+
298
+ logger.info(f"ゲームデータ保存完了: {user_id[:8]}...")
299
+ return True
300
+
301
+ except Exception as e:
302
+ logger.error(f"ゲームデータ保存エラー: {e}")
303
+ return False
304
+
305
+ def get_user_info(self, user_id: str) -> Optional[Dict[str, Any]]:
306
+ """
307
+ ユーザー情報を取得
308
+
309
+ Args:
310
+ user_id: ユーザーID
311
+
312
+ Returns:
313
+ ユーザー情報
314
+ """
315
+ try:
316
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
317
+
318
+ if os.path.exists(user_file):
319
+ with open(user_file, 'r', encoding='utf-8') as f:
320
+ return json.load(f)
321
+
322
+ return None
323
+
324
+ except Exception as e:
325
+ logger.error(f"ユーザー情報取得エラー: {e}")
326
+ return None
327
+
328
+ def delete_user_data(self, user_id: str) -> bool:
329
+ """
330
+ ユーザーデータを削除
331
+
332
+ Args:
333
+ user_id: 削除するユーザーID
334
+
335
+ Returns:
336
+ 削除成功時True
337
+ """
338
+ try:
339
+ user_file = os.path.join(self.user_data_dir, f"{user_id}.json")
340
+
341
+ if os.path.exists(user_file):
342
+ os.remove(user_file)
343
+ logger.info(f"ユーザーデータ削除: {user_id[:8]}...")
344
+
345
+ # Cookieも削除
346
+ if self.cookies:
347
+ if self.cookie_name in self.cookies:
348
+ del self.cookies[self.cookie_name]
349
+ self.cookies.save()
350
+
351
+ # セッション状態からも削除
352
+ if 'persistent_user_id' in st.session_state:
353
+ del st.session_state.persistent_user_id
354
+
355
+ return True
356
+
357
+ except Exception as e:
358
+ logger.error(f"ユーザーデータ削除エラー: {e}")
359
+ return False
360
+
361
+ def list_all_users(self) -> List[Dict[str, Any]]:
362
+ """
363
+ 全ユーザーの一覧を取得(管理用)
364
+
365
+ Returns:
366
+ ユーザー情報のリスト
367
+ """
368
+ try:
369
+ users = []
370
+
371
+ for filename in os.listdir(self.user_data_dir):
372
+ if filename.endswith('.json'):
373
+ user_file = os.path.join(self.user_data_dir, filename)
374
+
375
+ try:
376
+ with open(user_file, 'r', encoding='utf-8') as f:
377
+ user_data = json.load(f)
378
+
379
+ # 基本情報のみ抽出
380
+ user_info = {
381
+ "user_id": user_data.get("user_id", "unknown")[:8] + "...",
382
+ "created_at": user_data.get("created_at", "unknown"),
383
+ "last_access": user_data.get("last_access", "unknown"),
384
+ "has_game_data": "game_data" in user_data,
385
+ "file_size": os.path.getsize(user_file)
386
+ }
387
+
388
+ users.append(user_info)
389
+
390
+ except Exception as e:
391
+ logger.warning(f"ユーザーファイル読み込みエラー {filename}: {e}")
392
+
393
+ return users
394
+
395
+ except Exception as e:
396
+ logger.error(f"ユーザー一覧取得エラー: {e}")
397
+ return []
398
+
399
+ def cleanup_old_users(self, days_threshold: int = 30) -> int:
400
+ """
401
+ 古いユーザーデータをクリーンアップ
402
+
403
+ Args:
404
+ days_threshold: 削除対象の日数閾値
405
+
406
+ Returns:
407
+ 削除されたユーザー数
408
+ """
409
+ try:
410
+ current_time = datetime.now()
411
+ deleted_count = 0
412
+
413
+ for filename in os.listdir(self.user_data_dir):
414
+ if filename.endswith('.json'):
415
+ user_file = os.path.join(self.user_data_dir, filename)
416
+
417
+ try:
418
+ with open(user_file, 'r', encoding='utf-8') as f:
419
+ user_data = json.load(f)
420
+
421
+ last_access = datetime.fromisoformat(user_data.get("last_access", ""))
422
+ if (current_time - last_access).days > days_threshold:
423
+ os.remove(user_file)
424
+ deleted_count += 1
425
+ logger.info(f"古いユーザーデータ削除: {filename}")
426
+
427
+ except Exception as e:
428
+ logger.warning(f"クリーンアップ処理エラー {filename}: {e}")
429
+
430
+ logger.info(f"ユーザーデータクリーンアップ完了: {deleted_count}件削除")
431
+ return deleted_count
432
+
433
+ except Exception as e:
434
+ logger.error(f"クリーンアップエラー: {e}")
435
+ return 0
436
+
437
+ def get_storage_stats(self) -> Dict[str, Any]:
438
+ """
439
+ ストレージ使用状況を取得
440
+
441
+ Returns:
442
+ ストレージ統計情報
443
+ """
444
+ try:
445
+ stats = {
446
+ "user_count": 0,
447
+ "total_size": 0,
448
+ "storage_path": self.user_data_dir,
449
+ "cookie_enabled": self.cookies is not None
450
+ }
451
+
452
+ if os.path.exists(self.user_data_dir):
453
+ for filename in os.listdir(self.user_data_dir):
454
+ if filename.endswith('.json'):
455
+ user_file = os.path.join(self.user_data_dir, filename)
456
+ stats["user_count"] += 1
457
+ stats["total_size"] += os.path.getsize(user_file)
458
+
459
+ return stats
460
+
461
+ except Exception as e:
462
+ logger.error(f"ストレージ統計取得エラー: {e}")
463
+ return {"error": str(e)}
requirements.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.28.0
2
+ python-dotenv>=1.0.0
3
+ openai>=1.0.0
4
+ groq>=0.4.0
5
+ requests>=2.25.0
6
+ unidic-lite
7
+ typing-extensions>=4.0.0
8
+ transformers>=4.20.0
9
+ torch>=1.12.0
10
+ sentencepiece>=0.1.95
11
+ fugashi>=1.1.0
12
+ ipadic>=1.0.0
13
+ aiohttp>=3.8.0
14
+ aiofiles>=23.0.0
15
+ asyncio-throttle>=1.0.0
16
+ pydantic>=2.0.0
17
+ fastapi>=0.104.0
18
+ uvicorn>=0.24.0
requirements_persistent.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # 永続ストレージ対応ユーザー管理システム用の追加依存関係
2
+ streamlit-cookies-manager==0.2.0
requirements_session.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # FastAPIセッション管理サーバー用の追加依存関係
2
+ fastapi==0.104.1
3
+ uvicorn[standard]==0.24.0
4
+ requests==2.31.0
ribinngu-hiru.jpg ADDED

Git LFS Details

  • SHA256: f0f3ac5b5101c730db7459882678f1ee730b597c8e3de42098472df9071833d2
  • Pointer size: 132 Bytes
  • Size of remote file: 1.93 MB
ribinngu-yoru-on.jpg ADDED

Git LFS Details

  • SHA256: f9f18e27879c67372c18edbaf8c6fbccbf79637162b810f5559a675a8ccfe804
  • Pointer size: 132 Bytes
  • Size of remote file: 2.1 MB
session_api_client.py ADDED
@@ -0,0 +1,494 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPIセッション管理クライアント
3
+ HttpOnlyクッキーによるセキュアなセッション管理のクライアント側実装
4
+ """
5
+ import requests
6
+ import json
7
+ import os
8
+ from datetime import datetime
9
+ from typing import Optional, Dict, Any
10
+ import streamlit as st
11
+ import logging
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ class SessionAPIClient:
16
+ """FastAPIセッションサーバーとの通信クライアント"""
17
+
18
+ def __init__(self, api_base_url: str = None):
19
+ """
20
+ 初期化
21
+
22
+ Args:
23
+ api_base_url: FastAPIサーバーのベースURL(Noneの場合は自動決定)
24
+ """
25
+ # Hugging Face Spacesでの実行を考慮してベースURLを自動決定
26
+ if api_base_url is None:
27
+ is_spaces = os.getenv("SPACE_ID") is not None
28
+ if is_spaces:
29
+ # Hugging Face Spacesでは同一コンテナ内なのでlocalhostを使用
30
+ api_base_url = "http://localhost:8000"
31
+ else:
32
+ api_base_url = "http://127.0.0.1:8000"
33
+
34
+ self.api_base_url = api_base_url
35
+ self.session = requests.Session()
36
+
37
+ # リクエストタイムアウト設定
38
+ self.timeout = 10
39
+
40
+ # セッション情報をStreamlitの状態に保存
41
+ if 'session_info' not in st.session_state:
42
+ st.session_state.session_info = {}
43
+
44
+ def create_session(self) -> Optional[str]:
45
+ """
46
+ 新しいセッションを作成
47
+
48
+ Returns:
49
+ セッションID(成功時)、None(失敗時)
50
+ """
51
+ try:
52
+ url = f"{self.api_base_url}/session/create"
53
+ response = self.session.post(url, timeout=self.timeout)
54
+
55
+ if response.status_code == 200:
56
+ data = response.json()
57
+ session_id = data.get('session_id')
58
+
59
+ # セッション情報を保存
60
+ st.session_state.session_info = {
61
+ 'session_id': session_id,
62
+ 'created_at': datetime.now().isoformat(),
63
+ 'status': 'active'
64
+ }
65
+
66
+ logger.info(f"新規セッション作成成功: {session_id[:8]}...")
67
+ return session_id
68
+ else:
69
+ logger.error(f"セッション作成失敗: {response.status_code}")
70
+ return None
71
+
72
+ except requests.exceptions.RequestException as e:
73
+ logger.error(f"セッション作成リクエストエラー: {e}")
74
+ return None
75
+ except Exception as e:
76
+ logger.error(f"セッション作成エラー: {e}")
77
+ return None
78
+
79
+ def validate_session(self) -> bool:
80
+ """
81
+ 現在のセッションの有効性を検証
82
+
83
+ Returns:
84
+ 有効な場合True
85
+ """
86
+ try:
87
+ # session_infoが存在しない場合は無効とみなす
88
+ if 'session_info' not in st.session_state:
89
+ logger.debug("session_info未存在 - セッション無効")
90
+ return False
91
+
92
+ url = f"{self.api_base_url}/session/validate"
93
+ response = self.session.post(url, timeout=self.timeout)
94
+
95
+ if response.status_code == 200:
96
+ data = response.json()
97
+ is_valid = data.get('valid', False)
98
+
99
+ if is_valid:
100
+ # セッション情報を更新
101
+ session_id = data.get('session_id')
102
+ st.session_state.session_info.update({
103
+ 'session_id': session_id,
104
+ 'last_validated': datetime.now().isoformat(),
105
+ 'status': 'active'
106
+ })
107
+ logger.debug(f"セッション検証成功: {session_id[:8]}...")
108
+ else:
109
+ # 無効なセッション
110
+ if 'session_info' in st.session_state:
111
+ st.session_state.session_info['status'] = 'invalid'
112
+ logger.warning("セッション検証失敗: 無効なセッション")
113
+
114
+ return is_valid
115
+ else:
116
+ logger.error(f"セッション検証失敗: {response.status_code}")
117
+ return False
118
+
119
+ except requests.exceptions.RequestException as e:
120
+ logger.error(f"セッション検証リクエストエラー: {e}")
121
+ return False
122
+ except Exception as e:
123
+ logger.error(f"セッション検証エラー: {e}")
124
+ return False
125
+
126
+ def get_session_info(self) -> Optional[Dict[str, Any]]:
127
+ """
128
+ セッション情報を取得
129
+
130
+ Returns:
131
+ セッション情報(成功時)、None(失敗時)
132
+ """
133
+ try:
134
+ url = f"{self.api_base_url}/session/info"
135
+ response = self.session.get(url, timeout=self.timeout)
136
+
137
+ if response.status_code == 200:
138
+ data = response.json()
139
+
140
+ # セッション情報を更新
141
+ st.session_state.session_info.update({
142
+ 'session_id': data.get('session_id'),
143
+ 'created_at': data.get('created_at'),
144
+ 'last_access': data.get('last_access'),
145
+ 'status': 'active'
146
+ })
147
+
148
+ return data
149
+ else:
150
+ logger.error(f"セッション情報取得失敗: {response.status_code}")
151
+ return None
152
+
153
+ except requests.exceptions.RequestException as e:
154
+ logger.error(f"セッション情報取得リクエストエラー: {e}")
155
+ return None
156
+ except Exception as e:
157
+ logger.error(f"セッション情報取得エラー: {e}")
158
+ return None
159
+
160
+ def delete_session(self) -> bool:
161
+ """
162
+ 現在のセッションを削除
163
+
164
+ Returns:
165
+ 削除成功時True
166
+ """
167
+ try:
168
+ url = f"{self.api_base_url}/session/delete"
169
+ response = self.session.delete(url, timeout=self.timeout)
170
+
171
+ if response.status_code == 200:
172
+ # セッション情報をクリア
173
+ st.session_state.session_info = {
174
+ 'status': 'deleted',
175
+ 'deleted_at': datetime.now().isoformat()
176
+ }
177
+
178
+ logger.info("セッション削除成功")
179
+ return True
180
+ else:
181
+ logger.error(f"セッション削除失敗: {response.status_code}")
182
+ return False
183
+
184
+ except requests.exceptions.RequestException as e:
185
+ logger.error(f"セッション削除リクエストエラー: {e}")
186
+ return False
187
+ except Exception as e:
188
+ logger.error(f"セッション削除エラー: {e}")
189
+ return False
190
+
191
+ def get_or_create_session_id(self) -> str:
192
+ """
193
+ セッションIDを取得または新規作成
194
+ 複数セッション生成を防ぐため、既存セッション情報を最優先でチェック
195
+
196
+ Returns:
197
+ セッションID
198
+ """
199
+ try:
200
+ # 既存のセッション情報をチェック
201
+ existing_session_info = st.session_state.get('session_info', {})
202
+ existing_session_id = existing_session_info.get('session_id')
203
+
204
+ # 既存セッションがある場合は、基本的にそれを使用(検証は最小限に)
205
+ if existing_session_id:
206
+ logger.debug(f"既存セッション情報発見: {existing_session_id[:8]}...")
207
+
208
+ # セッション状態をチェック
209
+ session_status = existing_session_info.get('status', 'unknown')
210
+
211
+ # 明示的に無効とマークされていない限り、既存セッションを使用
212
+ if session_status != 'invalid':
213
+ logger.debug(f"既存セッション使用: {existing_session_id[:8]}... (status: {session_status})")
214
+ return existing_session_id
215
+ else:
216
+ logger.info(f"無効セッション検出: {existing_session_id[:8]}... - 新規作成")
217
+
218
+ # 新しいセッションを作成(一度だけ)
219
+ logger.info("新規セッション作成開始...")
220
+
221
+ # セッション作成中フラグを設定(重複作成防止)
222
+ if st.session_state.get('session_creating', False):
223
+ logger.warning("セッション作成中 - 待機")
224
+ # 既存のセッション情報があればそれを返す
225
+ if existing_session_id:
226
+ return existing_session_id
227
+ # なければフォールバック
228
+ import uuid
229
+ return str(uuid.uuid4())
230
+
231
+ st.session_state.session_creating = True
232
+
233
+ try:
234
+ if self.is_server_available():
235
+ session_id = self.create_session()
236
+ if session_id:
237
+ logger.info(f"新規セッション作成成功: {session_id[:8]}...")
238
+ return session_id
239
+
240
+ # フォールバック: ローカルセッションID生成
241
+ import uuid
242
+ fallback_id = str(uuid.uuid4())
243
+ logger.warning(f"フォールバックセッションID生成: {fallback_id[:8]}...")
244
+
245
+ # フォールバックセッション情報を保存
246
+ st.session_state.session_info = {
247
+ 'session_id': fallback_id,
248
+ 'created_at': datetime.now().isoformat(),
249
+ 'fallback_mode': True,
250
+ 'server_available': False,
251
+ 'status': 'fallback'
252
+ }
253
+
254
+ return fallback_id
255
+
256
+ finally:
257
+ # セッション作成中フラグをクリア
258
+ st.session_state.session_creating = False
259
+
260
+ except Exception as e:
261
+ logger.error(f"セッションID取得エラー: {e}")
262
+ # セッション作成中フラグをクリア
263
+ st.session_state.session_creating = False
264
+
265
+ # 最終フォールバック
266
+ import uuid
267
+ fallback_id = str(uuid.uuid4())
268
+ logger.error(f"最終フォールバックセッションID: {fallback_id[:8]}...")
269
+ return fallback_id
270
+
271
+ def is_server_available(self) -> bool:
272
+ """
273
+ FastAPIサーバーが利用可能かチェック
274
+
275
+ Returns:
276
+ 利用可能な場合True
277
+ """
278
+ try:
279
+ url = f"{self.api_base_url}/health"
280
+ response = self.session.get(url, timeout=5)
281
+ return response.status_code == 200
282
+ except:
283
+ return False
284
+
285
+ def get_session_status(self) -> Dict[str, Any]:
286
+ """
287
+ 現在のセッション状態を取得
288
+
289
+ Returns:
290
+ セッション状態の辞書
291
+ """
292
+ session_info = st.session_state.get('session_info', {})
293
+
294
+ return {
295
+ 'session_id': session_info.get('session_id', 'unknown')[:8] + "..." if session_info.get('session_id') else 'none',
296
+ 'status': session_info.get('status', 'unknown'),
297
+ 'created_at': session_info.get('created_at'),
298
+ 'last_access': session_info.get('last_access'),
299
+ 'last_validated': session_info.get('last_validated'),
300
+ 'server_available': self.is_server_available()
301
+ }
302
+
303
+ def reset_session(self) -> str:
304
+ """
305
+ セッションをリセット(削除して新規作成)
306
+
307
+ Returns:
308
+ 新しいセッションID
309
+ """
310
+ try:
311
+ # 既存セッションを削除
312
+ self.delete_session()
313
+
314
+ # 新しいセッションを作成
315
+ new_session_id = self.create_session()
316
+ if new_session_id:
317
+ logger.info(f"セッションリセット完了: {new_session_id[:8]}...")
318
+ return new_session_id
319
+
320
+ # フォールバック
321
+ import uuid
322
+ fallback_id = str(uuid.uuid4())
323
+ logger.warning(f"セッションリセット失敗、フォールバック使用: {fallback_id[:8]}...")
324
+ return fallback_id
325
+
326
+ except Exception as e:
327
+ logger.error(f"セッションリセットエラー: {e}")
328
+ import uuid
329
+ return str(uuid.uuid4())
330
+
331
+ def get_cookie_status(self) -> Dict[str, Any]:
332
+ """
333
+ 現在のCookie状態を取得
334
+
335
+ Returns:
336
+ Cookie状態の辞書
337
+ """
338
+ try:
339
+ cookie_info = {
340
+ 'count': len(self.session.cookies),
341
+ 'cookies': [],
342
+ 'has_session_cookie': False,
343
+ 'timestamp': datetime.now().isoformat()
344
+ }
345
+
346
+ for cookie in self.session.cookies:
347
+ cookie_data = {
348
+ 'name': cookie.name,
349
+ 'domain': cookie.domain,
350
+ 'path': cookie.path,
351
+ 'secure': cookie.secure,
352
+ 'expires': cookie.expires
353
+ }
354
+ cookie_info['cookies'].append(cookie_data)
355
+
356
+ # セッション関連のCookieをチェック
357
+ if 'session' in cookie.name.lower():
358
+ cookie_info['has_session_cookie'] = True
359
+
360
+ return cookie_info
361
+
362
+ except Exception as e:
363
+ logger.error(f"Cookie状態取得エラー: {e}")
364
+ return {
365
+ 'count': 0,
366
+ 'cookies': [],
367
+ 'has_session_cookie': False,
368
+ 'error': str(e),
369
+ 'timestamp': datetime.now().isoformat()
370
+ }
371
+
372
+ def full_reset_session(self) -> Dict[str, Any]:
373
+ """
374
+ フルリセット(Cookie削除 + 新規セッション作成)
375
+ サーバー接続エラーでも動作するフォールバック機能付き
376
+
377
+ Returns:
378
+ リセット結果の辞書
379
+ """
380
+ try:
381
+ result = {
382
+ 'success': False,
383
+ 'old_session_id': None,
384
+ 'new_session_id': None,
385
+ 'message': '',
386
+ 'timestamp': datetime.now().isoformat(),
387
+ 'cookie_reset': False,
388
+ 'session_created': False,
389
+ 'server_available': False,
390
+ 'fallback_mode': False
391
+ }
392
+
393
+ # 現在のセッションIDを記録
394
+ current_session_info = st.session_state.get('session_info', {})
395
+ old_session_id = current_session_info.get('session_id', st.session_state.get('user_id', 'unknown'))
396
+ result['old_session_id'] = old_session_id[:8] + "..." if len(old_session_id) > 8 else old_session_id
397
+
398
+ logger.info(f"フルリセット開始 - 旧セッション: {result['old_session_id']}")
399
+
400
+ # 1. サーバー接続テスト
401
+ server_available = self._test_server_connection()
402
+ result['server_available'] = server_available
403
+
404
+ if server_available:
405
+ # サーバーが利用可能な場合の通常処理
406
+ logger.info("サーバー利用可能 - 通常のフルリセット実行")
407
+ delete_success = self.delete_session()
408
+ logger.info(f"セッション削除結果: {delete_success}")
409
+ else:
410
+ # サーバーが利用できない場合のフォールバック処理
411
+ logger.warning("サーバー接続不可 - フォールバックモードでリセット実行")
412
+ result['fallback_mode'] = True
413
+
414
+ # 2. セッション情報を完全クリア
415
+ if 'session_info' in st.session_state:
416
+ del st.session_state.session_info
417
+
418
+ # 3. 新しいrequestsセッションを作成(Cookie完全クリア)
419
+ old_session = self.session
420
+ cookie_count_before = len(old_session.cookies)
421
+
422
+ self.session.close()
423
+ self.session = requests.Session()
424
+
425
+ # Cookieが完全にクリアされたことを確認
426
+ cookie_count_after = len(self.session.cookies)
427
+ result['cookie_reset'] = cookie_count_after == 0
428
+
429
+ logger.info(f"Cookie状態 - 削除前: {cookie_count_before}個, 削除後: {cookie_count_after}個")
430
+
431
+ # 4. 新しいセッションを作成
432
+ if server_available:
433
+ # サーバー経由でセッション作成
434
+ new_session_id = self.create_session()
435
+ else:
436
+ # フォールバック: ローカルでセッションID生成
437
+ import uuid
438
+ new_session_id = str(uuid.uuid4())
439
+ logger.info(f"フォールバックモード: ローカルセッションID生成 - {new_session_id[:8]}...")
440
+
441
+ if new_session_id:
442
+ result['success'] = True
443
+ result['session_created'] = True
444
+ result['new_session_id'] = new_session_id[:8] + "..."
445
+
446
+ if result['fallback_mode']:
447
+ result['message'] = 'フルリセット成功(フォールバックモード) - Cookie削除&ローカルセッション作成完了'
448
+ else:
449
+ result['message'] = 'フルリセット成功 - Cookie削除&新規セッション作成完了'
450
+
451
+ logger.info(f"フルリセット成功: {result['old_session_id']} → {result['new_session_id']}")
452
+
453
+ # 新しいセッション情報をStreamlitセッションに保存
454
+ st.session_state.session_info = {
455
+ 'session_id': new_session_id,
456
+ 'created_at': datetime.now().isoformat(),
457
+ 'reset_count': st.session_state.get('reset_count', 0) + 1,
458
+ 'fallback_mode': result['fallback_mode'],
459
+ 'server_available': result['server_available']
460
+ }
461
+
462
+ else:
463
+ result['message'] = 'Cookie削除成功、セッション作成失敗'
464
+ logger.error("フルリセット: 新規セッション作成失敗")
465
+
466
+ return result
467
+
468
+ except Exception as e:
469
+ logger.error(f"フルリセットエラー: {e}")
470
+ return {
471
+ 'success': False,
472
+ 'old_session_id': 'error',
473
+ 'new_session_id': None,
474
+ 'message': f'エラー: {str(e)}',
475
+ 'timestamp': datetime.now().isoformat(),
476
+ 'cookie_reset': False,
477
+ 'session_created': False,
478
+ 'server_available': False,
479
+ 'fallback_mode': True
480
+ }
481
+
482
+ def _test_server_connection(self) -> bool:
483
+ """
484
+ サーバー接続をテストする
485
+
486
+ Returns:
487
+ 接続可能かどうか
488
+ """
489
+ try:
490
+ response = requests.get(f"{self.base_url}/health", timeout=2)
491
+ return response.status_code == 200
492
+ except Exception as e:
493
+ logger.debug(f"サーバー接続テスト失敗: {e}")
494
+ return False
session_api_server.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPIベースのセッション管理サーバー
3
+ HttpOnlyクッキーによるセキュアなセッション管理を提供
4
+ """
5
+ from fastapi import FastAPI, Request, Response, HTTPException
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ import uuid
8
+ import json
9
+ import os
10
+ import time
11
+ from datetime import datetime, timedelta
12
+ from typing import Optional, Dict, Any
13
+ import logging
14
+
15
+ # ログ設定
16
+ logging.basicConfig(level=logging.INFO)
17
+ logger = logging.getLogger(__name__)
18
+
19
+ app = FastAPI(title="Mari Session API", version="1.0.0")
20
+
21
+ # CORS設定(Streamlitからのアクセスを許可)
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=[
25
+ "http://localhost:8501",
26
+ "https://localhost:8501",
27
+ "http://127.0.0.1:8501",
28
+ "https://127.0.0.1:8501",
29
+ "*" # Hugging Face Spacesでの実行を考慮
30
+ ],
31
+ allow_credentials=True,
32
+ allow_methods=["GET", "POST", "PUT", "DELETE"],
33
+ allow_headers=["*"],
34
+ )
35
+
36
+ class SessionManager:
37
+ """セッション管理クラス"""
38
+
39
+ def __init__(self, storage_path: str = "session_data"):
40
+ self.storage_path = storage_path
41
+ self.cookie_name = "mari_session_id"
42
+ self.session_duration_days = 7
43
+ self.cleanup_interval_hours = 24
44
+
45
+ # ストレージディレクトリを作成
46
+ os.makedirs(self.storage_path, exist_ok=True)
47
+
48
+ # 最後のクリーンアップ時刻を記録するファイル
49
+ self.cleanup_file = os.path.join(self.storage_path, "last_cleanup.json")
50
+
51
+ def create_session(self) -> str:
52
+ """新しいセッションを作成"""
53
+ session_id = str(uuid.uuid4())
54
+
55
+ session_data = {
56
+ 'session_id': session_id,
57
+ 'created_at': datetime.now().isoformat(),
58
+ 'last_access': datetime.now().isoformat(),
59
+ 'user_data': {}
60
+ }
61
+
62
+ # セッションファイルに保存
63
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
64
+ with open(session_file, 'w', encoding='utf-8') as f:
65
+ json.dump(session_data, f, ensure_ascii=False, indent=2)
66
+
67
+ logger.info(f"新規セッション作成: {session_id[:8]}...")
68
+ return session_id
69
+
70
+ def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
71
+ """セッションデータを取得"""
72
+ try:
73
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
74
+
75
+ if not os.path.exists(session_file):
76
+ return None
77
+
78
+ with open(session_file, 'r', encoding='utf-8') as f:
79
+ session_data = json.load(f)
80
+
81
+ # 期限チェック
82
+ last_access = datetime.fromisoformat(session_data.get('last_access', ''))
83
+ expiry_time = last_access + timedelta(days=self.session_duration_days)
84
+
85
+ if datetime.now() > expiry_time:
86
+ # 期限切れセッションを削除
87
+ os.remove(session_file)
88
+ logger.info(f"期限切れセッション削除: {session_id[:8]}...")
89
+ return None
90
+
91
+ return session_data
92
+
93
+ except Exception as e:
94
+ logger.error(f"セッション取得エラー: {e}")
95
+ return None
96
+
97
+ def update_session_access(self, session_id: str) -> bool:
98
+ """セッションの最終アクセス時刻を更新"""
99
+ try:
100
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
101
+
102
+ if not os.path.exists(session_file):
103
+ return False
104
+
105
+ with open(session_file, 'r', encoding='utf-8') as f:
106
+ session_data = json.load(f)
107
+
108
+ session_data['last_access'] = datetime.now().isoformat()
109
+
110
+ with open(session_file, 'w', encoding='utf-8') as f:
111
+ json.dump(session_data, f, ensure_ascii=False, indent=2)
112
+
113
+ return True
114
+
115
+ except Exception as e:
116
+ logger.error(f"セッションアクセス時刻更新エラー: {e}")
117
+ return False
118
+
119
+ def delete_session(self, session_id: str) -> bool:
120
+ """セッションを削除"""
121
+ try:
122
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
123
+
124
+ if os.path.exists(session_file):
125
+ os.remove(session_file)
126
+ logger.info(f"セッション削除: {session_id[:8]}...")
127
+ return True
128
+
129
+ return False
130
+
131
+ except Exception as e:
132
+ logger.error(f"セッション削除エラー: {e}")
133
+ return False
134
+
135
+ def cleanup_expired_sessions(self):
136
+ """期限切れセッションのクリーンアップ"""
137
+ try:
138
+ current_time = datetime.now()
139
+ cleaned_count = 0
140
+
141
+ for filename in os.listdir(self.storage_path):
142
+ if filename.endswith('.json') and filename != 'last_cleanup.json':
143
+ session_file = os.path.join(self.storage_path, filename)
144
+
145
+ try:
146
+ with open(session_file, 'r', encoding='utf-8') as f:
147
+ session_data = json.load(f)
148
+
149
+ last_access = datetime.fromisoformat(session_data.get('last_access', ''))
150
+ expiry_time = last_access + timedelta(days=self.session_duration_days)
151
+
152
+ if current_time > expiry_time:
153
+ os.remove(session_file)
154
+ cleaned_count += 1
155
+
156
+ except Exception as e:
157
+ logger.warning(f"セッションファイル処理エラー {filename}: {e}")
158
+
159
+ if cleaned_count > 0:
160
+ logger.info(f"期限切れセッション {cleaned_count}件を削除")
161
+
162
+ except Exception as e:
163
+ logger.error(f"セッションクリーンアップエラー: {e}")
164
+
165
+ # セッションマネージャーのインスタンス
166
+ session_manager = SessionManager()
167
+
168
+ @app.post("/session/create")
169
+ async def create_session(response: Response):
170
+ """新しいセッションを作成してCookieを設定"""
171
+ try:
172
+ # 期限切れセッションのクリーンアップ
173
+ session_manager.cleanup_expired_sessions()
174
+
175
+ # 新しいセッションを作成
176
+ session_id = session_manager.create_session()
177
+
178
+ # HttpOnlyクッキーを設定(Hugging Face Spaces対応)
179
+ is_production = os.getenv("SPACE_ID") is not None # Hugging Face Spacesの環境変数
180
+
181
+ response.set_cookie(
182
+ key=session_manager.cookie_name,
183
+ value=session_id,
184
+ max_age=session_manager.session_duration_days * 24 * 60 * 60, # 7日間
185
+ httponly=True,
186
+ secure=is_production, # 本番環境(HTTPS)でのみSecure属性を有効
187
+ samesite="lax" if is_production else "strict" # 本番環境では緩和
188
+ )
189
+
190
+ return {
191
+ "status": "success",
192
+ "session_id": session_id,
193
+ "message": "セッションが作成されました"
194
+ }
195
+
196
+ except Exception as e:
197
+ logger.error(f"セッション作成エラー: {e}")
198
+ raise HTTPException(status_code=500, detail="セッション作成に失敗しました")
199
+
200
+ @app.get("/session/info")
201
+ async def get_session_info(request: Request):
202
+ """現在のセッション情報を取得"""
203
+ try:
204
+ # CookieからセッションIDを取得
205
+ session_id = request.cookies.get(session_manager.cookie_name)
206
+
207
+ if not session_id:
208
+ raise HTTPException(status_code=401, detail="セッションが見つかりません")
209
+
210
+ # セッションデータを取得
211
+ session_data = session_manager.get_session(session_id)
212
+
213
+ if not session_data:
214
+ raise HTTPException(status_code=401, detail="無効なセッションです")
215
+
216
+ # アクセス時刻を更新
217
+ session_manager.update_session_access(session_id)
218
+
219
+ return {
220
+ "status": "success",
221
+ "session_id": session_id,
222
+ "created_at": session_data.get('created_at'),
223
+ "last_access": session_data.get('last_access')
224
+ }
225
+
226
+ except HTTPException:
227
+ raise
228
+ except Exception as e:
229
+ logger.error(f"セッション情報取得エラー: {e}")
230
+ raise HTTPException(status_code=500, detail="セッション情報の取得に失敗しました")
231
+
232
+ @app.post("/session/validate")
233
+ async def validate_session(request: Request):
234
+ """セッションの有効性を検証"""
235
+ try:
236
+ # CookieからセッションIDを取得
237
+ session_id = request.cookies.get(session_manager.cookie_name)
238
+
239
+ if not session_id:
240
+ return {"valid": False, "message": "セッションが見つかりません"}
241
+
242
+ # セッションデータを取得
243
+ session_data = session_manager.get_session(session_id)
244
+
245
+ if not session_data:
246
+ return {"valid": False, "message": "無効なセッションです"}
247
+
248
+ # アクセス時刻を更新
249
+ session_manager.update_session_access(session_id)
250
+
251
+ return {
252
+ "valid": True,
253
+ "session_id": session_id,
254
+ "message": "有効なセッションです"
255
+ }
256
+
257
+ except Exception as e:
258
+ logger.error(f"セッション検証エラー: {e}")
259
+ return {"valid": False, "message": "セッション検証に失敗しました"}
260
+
261
+ @app.delete("/session/delete")
262
+ async def delete_session(request: Request, response: Response):
263
+ """セッションを削除"""
264
+ try:
265
+ # CookieからセッションIDを取得
266
+ session_id = request.cookies.get(session_manager.cookie_name)
267
+
268
+ if session_id:
269
+ # セッションファイルを削除
270
+ session_manager.delete_session(session_id)
271
+
272
+ # Cookieを削除(Hugging Face Spaces対応)
273
+ is_production = os.getenv("SPACE_ID") is not None
274
+
275
+ response.delete_cookie(
276
+ key=session_manager.cookie_name,
277
+ httponly=True,
278
+ secure=is_production,
279
+ samesite="lax" if is_production else "strict"
280
+ )
281
+
282
+ return {
283
+ "status": "success",
284
+ "message": "セッションが削除されました"
285
+ }
286
+
287
+ except Exception as e:
288
+ logger.error(f"セッション削除エラー: {e}")
289
+ raise HTTPException(status_code=500, detail="セッション削除に失敗しました")
290
+
291
+ @app.get("/health")
292
+ async def health_check():
293
+ """ヘルスチェック"""
294
+ return {"status": "healthy", "timestamp": datetime.now().isoformat()}
295
+
296
+ if __name__ == "__main__":
297
+ import uvicorn
298
+ uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")
session_cookie_manager.py ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ セッションCookie管理システム
3
+ UUIDベースのユーザー識別とCookie管理を提供する
4
+ """
5
+ import uuid
6
+ import json
7
+ import os
8
+ import time
9
+ from datetime import datetime, timedelta
10
+ from typing import Optional, Dict, Any
11
+ import streamlit as st
12
+ import logging
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ class SessionCookieManager:
17
+ """セッションCookie管理クラス"""
18
+
19
+ def __init__(self, storage_path: str = "session_data"):
20
+ """
21
+ 初期化
22
+
23
+ Args:
24
+ storage_path: セッションデータの保存パス
25
+ """
26
+ self.storage_path = storage_path
27
+ self.cookie_name = "mari_session_id"
28
+ self.session_duration_days = 7
29
+ self.cleanup_interval_hours = 24
30
+
31
+ # ストレージディレクトリを作成
32
+ os.makedirs(self.storage_path, exist_ok=True)
33
+
34
+ # 最後のクリーンアップ時刻を記録するファイル
35
+ self.cleanup_file = os.path.join(self.storage_path, "last_cleanup.json")
36
+
37
+ def get_or_create_session_id(self) -> str:
38
+ """
39
+ セッションIDを取得または新規作成
40
+
41
+ Returns:
42
+ セッションID(UUID4形式)
43
+ """
44
+ try:
45
+ # 既存のセッションIDを確認
46
+ session_id = self._get_session_id_from_state()
47
+
48
+ if session_id and self._is_valid_session(session_id):
49
+ # 有効なセッションIDが存在する場合
50
+ self._update_session_access_time(session_id)
51
+ logger.info(f"既存セッションID使用: {session_id[:8]}...")
52
+ return session_id
53
+
54
+ # 新しいセッションIDを生成
55
+ session_id = str(uuid.uuid4())
56
+ self._create_new_session(session_id)
57
+ logger.info(f"新規セッションID生成: {session_id[:8]}...")
58
+
59
+ return session_id
60
+
61
+ except Exception as e:
62
+ logger.error(f"セッションID取得エラー: {e}")
63
+ # フォールバック:一時的なセッションID
64
+ return str(uuid.uuid4())
65
+
66
+ def _get_session_id_from_state(self) -> Optional[str]:
67
+ """
68
+ Streamlitの状態からセッションIDを取得
69
+
70
+ Returns:
71
+ セッションID(存在しない場合はNone)
72
+ """
73
+ # Streamlitのセッション状態から取得
74
+ session_id = st.session_state.get('mari_session_id')
75
+
76
+ if session_id:
77
+ return session_id
78
+
79
+ # URLパラメータから取得を試行(フォールバック)
80
+ query_params = st.query_params
81
+ if 'session_id' in query_params:
82
+ session_id = query_params['session_id']
83
+ if self._is_valid_uuid(session_id):
84
+ return session_id
85
+
86
+ return None
87
+
88
+ def _is_valid_uuid(self, uuid_string: str) -> bool:
89
+ """
90
+ UUIDの形式が正しいかチェック
91
+
92
+ Args:
93
+ uuid_string: チェックするUUID文字列
94
+
95
+ Returns:
96
+ 有効な場合True
97
+ """
98
+ try:
99
+ uuid.UUID(uuid_string, version=4)
100
+ return True
101
+ except (ValueError, TypeError):
102
+ return False
103
+
104
+ def _is_valid_session(self, session_id: str) -> bool:
105
+ """
106
+ セッションが有効かチェック
107
+
108
+ Args:
109
+ session_id: チェックするセッションID
110
+
111
+ Returns:
112
+ 有効な場合True
113
+ """
114
+ try:
115
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
116
+
117
+ if not os.path.exists(session_file):
118
+ return False
119
+
120
+ with open(session_file, 'r', encoding='utf-8') as f:
121
+ session_data = json.load(f)
122
+
123
+ # 最終アクセス時刻をチェック
124
+ last_access = datetime.fromisoformat(session_data.get('last_access', ''))
125
+ expiry_time = last_access + timedelta(days=self.session_duration_days)
126
+
127
+ return datetime.now() < expiry_time
128
+
129
+ except Exception as e:
130
+ logger.warning(f"セッション検証エラー: {e}")
131
+ return False
132
+
133
+ def _create_new_session(self, session_id: str) -> None:
134
+ """
135
+ 新しいセッションを作成
136
+
137
+ Args:
138
+ session_id: 新しいセッションID
139
+ """
140
+ try:
141
+ # セッションデータを作成
142
+ session_data = {
143
+ 'session_id': session_id,
144
+ 'created_at': datetime.now().isoformat(),
145
+ 'last_access': datetime.now().isoformat(),
146
+ 'user_agent': self._get_user_agent(),
147
+ 'ip_hash': self._get_ip_hash()
148
+ }
149
+
150
+ # セッションファイルに保存
151
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
152
+ with open(session_file, 'w', encoding='utf-8') as f:
153
+ json.dump(session_data, f, ensure_ascii=False, indent=2)
154
+
155
+ # Streamlitの状態に保存
156
+ st.session_state.mari_session_id = session_id
157
+
158
+ # Cookie設定のJavaScriptを生成(セキュア設定)
159
+ self._set_secure_cookie(session_id)
160
+
161
+ except Exception as e:
162
+ logger.error(f"新規セッション作成エラー: {e}")
163
+
164
+ def _update_session_access_time(self, session_id: str) -> None:
165
+ """
166
+ セッションの最終アクセス時刻を更新
167
+
168
+ Args:
169
+ session_id: 更新するセッションID
170
+ """
171
+ try:
172
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
173
+
174
+ if os.path.exists(session_file):
175
+ with open(session_file, 'r', encoding='utf-8') as f:
176
+ session_data = json.load(f)
177
+
178
+ session_data['last_access'] = datetime.now().isoformat()
179
+
180
+ with open(session_file, 'w', encoding='utf-8') as f:
181
+ json.dump(session_data, f, ensure_ascii=False, indent=2)
182
+
183
+ except Exception as e:
184
+ logger.warning(f"セッションアクセス時刻更新エラー: {e}")
185
+
186
+ def _set_secure_cookie(self, session_id: str) -> None:
187
+ """
188
+ セキュアなCookieを設定
189
+
190
+ Args:
191
+ session_id: 設定するセッションID
192
+ """
193
+ try:
194
+ # セキュアCookie設定のJavaScript
195
+ cookie_js = f"""
196
+ <script>
197
+ // セキュアCookieの設定
198
+ function setSecureCookie() {{
199
+ const sessionId = '{session_id}';
200
+ const expiryDays = {self.session_duration_days};
201
+ const expiryDate = new Date();
202
+ expiryDate.setTime(expiryDate.getTime() + (expiryDays * 24 * 60 * 60 * 1000));
203
+
204
+ // セキュア属性付きCookieを設定
205
+ let cookieString = `{self.cookie_name}=${{sessionId}}; expires=${{expiryDate.toUTCString()}}; path=/; SameSite=Strict`;
206
+
207
+ // HTTPS環境の場合はSecure属性を追加
208
+ if (location.protocol === 'https:') {{
209
+ cookieString += '; Secure';
210
+ }}
211
+
212
+ // HttpOnly属性は JavaScript では設定できないため、サーバーサイドで設定が必要
213
+ document.cookie = cookieString;
214
+
215
+ console.log('セッションCookie設定完了:', sessionId.substring(0, 8) + '...');
216
+ }}
217
+
218
+ // ページ読み込み時にCookieを設定
219
+ if (document.readyState === 'loading') {{
220
+ document.addEventListener('DOMContentLoaded', setSecureCookie);
221
+ }} else {{
222
+ setSecureCookie();
223
+ }}
224
+ </script>
225
+ """
226
+
227
+ st.markdown(cookie_js, unsafe_allow_html=True)
228
+
229
+ except Exception as e:
230
+ logger.error(f"セキュアCookie設定エラー: {e}")
231
+
232
+ def _get_user_agent(self) -> str:
233
+ """
234
+ ユーザーエージェントを取得(可能な場合)
235
+
236
+ Returns:
237
+ ユーザーエージェント文字列
238
+ """
239
+ try:
240
+ # Streamlitでは直接取得できないため、JavaScriptで取得
241
+ return "Streamlit-Client"
242
+ except Exception:
243
+ return "Unknown"
244
+
245
+ def _get_ip_hash(self) -> str:
246
+ """
247
+ IPアドレスのハッシュを取得(プライバシー保護)
248
+
249
+ Returns:
250
+ IPアドレスのハッシュ
251
+ """
252
+ try:
253
+ import hashlib
254
+ # 実際のIPアドレスは取得せず、セッション識別用のハッシュのみ
255
+ session_hash = hashlib.sha256(str(time.time()).encode()).hexdigest()[:16]
256
+ return session_hash
257
+ except Exception:
258
+ return "unknown"
259
+
260
+ def cleanup_expired_sessions(self) -> None:
261
+ """
262
+ 期限切れセッションのクリーンアップ
263
+ """
264
+ try:
265
+ # 前回のクリーンアップ時刻をチェック
266
+ if not self._should_cleanup():
267
+ return
268
+
269
+ current_time = datetime.now()
270
+ cleaned_count = 0
271
+
272
+ # セッションファイルをスキャン
273
+ for filename in os.listdir(self.storage_path):
274
+ if filename.endswith('.json') and filename != 'last_cleanup.json':
275
+ session_file = os.path.join(self.storage_path, filename)
276
+
277
+ try:
278
+ with open(session_file, 'r', encoding='utf-8') as f:
279
+ session_data = json.load(f)
280
+
281
+ last_access = datetime.fromisoformat(session_data.get('last_access', ''))
282
+ expiry_time = last_access + timedelta(days=self.session_duration_days)
283
+
284
+ if current_time > expiry_time:
285
+ os.remove(session_file)
286
+ cleaned_count += 1
287
+ logger.info(f"期限切れセッション削除: {filename}")
288
+
289
+ except Exception as e:
290
+ logger.warning(f"セッションファイル処理エラー {filename}: {e}")
291
+
292
+ # クリーンアップ時刻を記録
293
+ self._update_cleanup_time()
294
+
295
+ if cleaned_count > 0:
296
+ logger.info(f"セッションクリーンアップ完了: {cleaned_count}件削除")
297
+
298
+ except Exception as e:
299
+ logger.error(f"セッションクリーンアップエラー: {e}")
300
+
301
+ def _should_cleanup(self) -> bool:
302
+ """
303
+ クリーンアップが必要かチェック
304
+
305
+ Returns:
306
+ クリーンアップが必要な場合True
307
+ """
308
+ try:
309
+ if not os.path.exists(self.cleanup_file):
310
+ return True
311
+
312
+ with open(self.cleanup_file, 'r', encoding='utf-8') as f:
313
+ cleanup_data = json.load(f)
314
+
315
+ last_cleanup = datetime.fromisoformat(cleanup_data.get('last_cleanup', ''))
316
+ next_cleanup = last_cleanup + timedelta(hours=self.cleanup_interval_hours)
317
+
318
+ return datetime.now() > next_cleanup
319
+
320
+ except Exception:
321
+ return True
322
+
323
+ def _update_cleanup_time(self) -> None:
324
+ """
325
+ クリーンアップ時刻を更新
326
+ """
327
+ try:
328
+ cleanup_data = {
329
+ 'last_cleanup': datetime.now().isoformat()
330
+ }
331
+
332
+ with open(self.cleanup_file, 'w', encoding='utf-8') as f:
333
+ json.dump(cleanup_data, f, ensure_ascii=False, indent=2)
334
+
335
+ except Exception as e:
336
+ logger.warning(f"クリーンアップ時刻更新エラー: {e}")
337
+
338
+ def get_session_info(self, session_id: str) -> Dict[str, Any]:
339
+ """
340
+ セッション情報を取得
341
+
342
+ Args:
343
+ session_id: セッションID
344
+
345
+ Returns:
346
+ セッション情報の辞書
347
+ """
348
+ try:
349
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
350
+
351
+ if os.path.exists(session_file):
352
+ with open(session_file, 'r', encoding='utf-8') as f:
353
+ return json.load(f)
354
+
355
+ return {}
356
+
357
+ except Exception as e:
358
+ logger.error(f"セッション情報取得エラー: {e}")
359
+ return {}
360
+
361
+ def delete_session(self, session_id: str) -> bool:
362
+ """
363
+ セッションを削除
364
+
365
+ Args:
366
+ session_id: 削除するセッションID
367
+
368
+ Returns:
369
+ 削除成功時True
370
+ """
371
+ try:
372
+ session_file = os.path.join(self.storage_path, f"{session_id}.json")
373
+
374
+ if os.path.exists(session_file):
375
+ os.remove(session_file)
376
+ logger.info(f"セッション削除: {session_id[:8]}...")
377
+ return True
378
+
379
+ return False
380
+
381
+ except Exception as e:
382
+ logger.error(f"セッション削除エラー: {e}")
383
+ return False