KyosukeIchikawa commited on
Commit
5e96c67
·
1 Parent(s): 03fd559

feat: Implement comprehensive session persistence system

Browse files

- Add session state serialization and deserialization methods to UserSession
- Implement file-based session persistence with JSON storage
- Update app.py to restore existing sessions on browser reconnection
- Add automatic session saving on all state changes
- Exclude API keys from persistence for security
- Add comprehensive session persistence test suite
- Update documentation with session management architecture
- Add CLAUDE.md reference to design.md
- Add development rule prohibiting --no-verify

CLAUDE.md ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## 📖 **Important**: Read the Design Document First
6
+ Before working on this codebase, **read [docs/design.md](docs/design.md)** for comprehensive architectural overview, including:
7
+ - Session management system design
8
+ - Component architecture and patterns
9
+ - Multi-user session isolation
10
+ - State persistence implementation
11
+ - Testing strategies and patterns
12
+
13
+ ## Essential Commands
14
+
15
+ ### Setup and Environment
16
+ ```bash
17
+ make setup # Complete setup: deps, VOICEVOX, lint tools, pre-commit
18
+ make venv # Create virtual environment only
19
+ make install # Install Python packages only
20
+ make download-voicevox-core # Download VOICEVOX Core for audio generation
21
+ ```
22
+
23
+ ### Development
24
+ ```bash
25
+ make run # Start the Gradio application on port 7860
26
+ make lint # Run flake8 and mypy static analysis
27
+ make format # Auto-format code with black, isort, autoflake, autopep8
28
+ ```
29
+
30
+ ### Testing
31
+ ```bash
32
+ make test # Run all tests (unit + E2E)
33
+ make test-unit # Run unit tests only
34
+ make test-e2e # Run E2E tests (sets E2E_TEST_MODE=true)
35
+ make test-staged # Run tests only for staged files
36
+ ```
37
+
38
+ ### Pre-commit Hooks
39
+ ```bash
40
+ make pre-commit-install # Install pre-commit hooks
41
+ make pre-commit-run # Run pre-commit hooks manually
42
+ ```
43
+
44
+ ## Architecture Overview
45
+
46
+ **📋 For detailed architecture information, see [docs/design.md](docs/design.md)**
47
+
48
+ ### Session-Based Multi-User Design
49
+ - **UserSession**: Each user gets isolated state with unique session directories
50
+ - **Global Resources**: VOICEVOX Core manager shared across users for performance
51
+ - **Session Cleanup**: Automatic cleanup of sessions older than 1 day
52
+ - **File Isolation**: Per-session temp/output directories under `data/{temp,output}/{session_id}/`
53
+ - **State Persistence**: Automatic save/restore of session state via JSON serialization
54
+
55
+ ### Component Architecture
56
+ The codebase follows a clean component separation:
57
+
58
+ - **TextProcessor** (`yomitalk/components/text_processor.py`): LLM integration and script generation
59
+ - **AudioGenerator** (`yomitalk/components/audio_generator.py`): VOICEVOX integration and audio synthesis
60
+ - **ContentExtractor** (`yomitalk/components/content_extractor.py`): File/URL content extraction
61
+ - **PromptManager** (`yomitalk/prompt_manager.py`): Template-based prompt generation
62
+
63
+ ### Dual LLM Support
64
+ - **Unified Interface**: Both OpenAI and Gemini models implement the same interface
65
+ - **Runtime Switching**: Users can switch between APIs during their session
66
+ - **Template System**: Jinja2 templates in `yomitalk/templates/` for different document types
67
+ - **Character Mapping**: Dynamic character assignment for dialogue generation
68
+
69
+ ### Streaming Audio Pipeline
70
+ The audio generation follows a streaming pattern:
71
+ 1. **Script Generation**: LLM creates character dialogue
72
+ 2. **Character Extraction**: Parse dialogue into character-specific segments
73
+ 3. **Streaming Synthesis**: VOICEVOX generates audio chunks yielded immediately
74
+ 4. **Final Combination**: In-memory WAV combination for complete audio file
75
+
76
+ ### Session Persistence System
77
+ - **Automatic Save/Restore**: All user settings persist across browser sessions
78
+ - **Security**: API keys excluded from persistence for security reasons
79
+ - **File Storage**: Session state saved to `data/temp/{session_id}/session_state.json`
80
+ - **Auto-Save Triggers**: Every setting change automatically saves session state
81
+ - **Restoration Info**: Methods to detect missing API keys and session status
82
+
83
+ ### Key Design Patterns
84
+ - **Session Dependency Injection**: UserSession owns and manages component instances
85
+ - **Enum-Driven Configuration**: Type-safe configuration via Character, DocumentType, PodcastMode enums
86
+ - **Global Singleton**: VOICEVOX Core manager initialized once at startup
87
+ - **Template-Based Generation**: Jinja2 templates for flexible content generation
88
+
89
+ ## Testing Structure
90
+
91
+ ### Test Organization
92
+ - **Unit Tests** (`tests/unit/`): Component isolation with mocking
93
+ - **E2E Tests** (`tests/e2e/`): Full user workflows with BDD (Gherkin features)
94
+ - **Playwright Integration**: Browser automation for E2E testing
95
+ - **Test Data**: Isolated test data directories per test type
96
+
97
+ ### BDD Features
98
+ Located in `tests/e2e/features/`, written in Gherkin syntax:
99
+ - `audio_generation.feature`
100
+ - `file_upload.feature`
101
+ - `script_generation.feature`
102
+ - `text_management.feature`
103
+ - `url_extraction.feature`
104
+ - `voicevox_sharing.feature`
105
+
106
+ ## Important Implementation Notes
107
+
108
+ ### VOICEVOX Integration
109
+ - **Global Manager**: One instance shared across all users (expensive to initialize)
110
+ - **Character Support**: Zundamon, Shikoku Metan, Kyushu Sora, Chugoku Usagi, Chubu Tsurugi
111
+ - **English Handling**: Automatic katakana conversion for technical terms
112
+ - **Natural Speech**: Smart word splitting to avoid robotic delivery
113
+
114
+ ### Session Management
115
+ - **Isolation**: Each user gets completely isolated file system and state
116
+ - **Cleanup**: Automatic cleanup prevents disk space issues
117
+ - **State Persistence**: Audio generation state, LLM configuration maintained per session
118
+
119
+ ### Error Handling Patterns
120
+ - **Graceful Degradation**: Components fail gracefully with user-friendly messages
121
+ - **Resource Cleanup**: Proper cleanup of session files and temporary data
122
+ - **API Resilience**: Handle LLM API failures and VOICEVOX errors appropriately
123
+
124
+ ### Development Workflow
125
+ - **TDD Approach**: Write tests before implementation (per project rules)
126
+ - **Trunk-Based Development**: Direct commits to main branch
127
+ - **No --no-verify**: Pre-commit hooks must always run
128
+ - **English Comments**: Code comments and logs in English
129
+ - **Small Commits**: Frequent, small commits preferred
130
+
131
+ ## Working with This Codebase
132
+
133
+ ### When Adding Features
134
+ 1. **Start with Tests**: Write unit tests first (TDD approach)
135
+ 2. **Respect Session Boundaries**: Work within UserSession context
136
+ 3. **Use Components**: Leverage existing TextProcessor, AudioGenerator, ContentExtractor
137
+ 4. **Follow Templates**: Use PromptManager for any LLM interactions
138
+ 5. **Handle Both APIs**: Ensure new features work with both OpenAI and Gemini
139
+ 6. **Add Auto-Save**: If your feature modifies session state, add `user_session.auto_save()` calls
140
+
141
+ ### When Debugging
142
+ 1. **Check Session State**: User issues often relate to session-specific state
143
+ 2. **Component Boundaries**: Verify component interactions work correctly
144
+ 3. **VOICEVOX Status**: Audio issues usually relate to VOICEVOX Core availability
145
+ 4. **Template Rendering**: Script generation issues often in Jinja2 templates
146
+
147
+ ### Development Rules
148
+ - **NEVER use `--no-verify`**: All commits must pass pre-commit hooks
149
+ - **Fix issues properly**: Don't bypass linting, formatting, or type checking
150
+ - **Test before commit**: Ensure all tests pass before committing
151
+
152
+ ### Performance Considerations
153
+ - **VOICEVOX Shared**: Don't reinitialize VOICEVOX Core per user
154
+ - **Session Cleanup**: Old sessions auto-cleanup, but manual cleanup may be needed
155
+ - **Memory Usage**: Audio generation can be memory-intensive with long content
156
+ - **Streaming**: Use streaming patterns for better user experience
157
+
158
+ ### File Structure Key Points
159
+ - **Session Directories**: `data/temp/{session_id}/` and `data/output/{session_id}/`
160
+ - **Session State**: `data/temp/{session_id}/session_state.json` for persistence
161
+ - **Templates**: `yomitalk/templates/*.j2` for prompt generation
162
+ - **Components**: `yomitalk/components/` for core functionality
163
+ - **Models**: `yomitalk/models/` for LLM integrations
164
+ - **Common**: `yomitalk/common/` for enums and shared types
165
+ - **Session Management**: `yomitalk/user_session.py` for state persistence
docs/design.md CHANGED
@@ -16,6 +16,28 @@
16
  - pytest/pytest-bdd: テスト自動化とBDDによるE2Eテスト
17
  - playwright: ブラウザ自動化によるE2Eテスト
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  ## フォルダ構成
20
  - yomitalk/ - メインアプリケーションコード
21
  - common/ - 共通データモデルおよび定義
@@ -30,6 +52,7 @@
30
  - utils/ - ユーティリティ関数
31
  - app.py - Gradioアプリ構築
32
  - prompt_manager.py - プロンプト管理および生成
 
33
  - templates/ - テンプレートファイル
34
  - common_podcast_utils.j2 - 共通のポッドキャスト生成ユーティリティ
35
  - paper_to_podcast.j2 - 論文解説用テンプレート
@@ -43,7 +66,11 @@
43
  - favicon.ico - ファビコン
44
  - data/ - 一時データ保存用
45
  - temp/ - アップロードされたファイルの一時保存
 
 
 
46
  - output/ - 生成された音声ファイル
 
47
  - logs/ - ログファイル保存用
48
  - tests/ - テストコード
49
  - data/ - テスト用データ
@@ -82,6 +109,11 @@
82
  - OpenAI APIとGoogle Gemini APIの切り替え機能
83
  - 各APIのモデル選択とパラメータ調整機能
84
  - トークン使用状況の表示機能
 
 
 
 
 
85
 
86
  ## コーディング規則
87
  - PEP 8準拠のPythonコード
@@ -110,6 +142,7 @@
110
  - ユニットテストによる各コンポーネントの個別検証
111
  - テストファイルは `tests/unit/` ディレクトリに配置
112
  - 各クラス・モジュールごとに独立したテストファイルを作成
 
113
  - モックを使用したAPIのテスト(OpenAI API、Gemini API)
114
  - テスト用のサンプルPDFおよびテキストデータを用意した自動テスト
115
  - GitHubワークフローによるCI自動実行
 
16
  - pytest/pytest-bdd: テスト自動化とBDDによるE2Eテスト
17
  - playwright: ブラウザ自動化によるE2Eテスト
18
 
19
+ ## アーキテクチャ概要
20
+
21
+ ### セッション管理システム
22
+ - **マルチユーザー対応**: 各ユーザーがGradioセッションハッシュに基づく独立したセッション状態を保持
23
+ - **状態の永続化**: ユーザー設定とセッション状態をJSONファイルとして自動保存・復元
24
+ - **セキュリティ配慮**: APIキーは保存せず、セッション復元時に再入力を要求
25
+ - **自動クリーンアップ**: 1日以上古いセッションディレクトリの自動削除
26
+
27
+ ### コンポーネント設計
28
+ - **UserSession**: セッション管理のコアクラス
29
+ - 各ユーザーの独立したTextProcessorとAudioGeneratorインスタンスを管理
30
+ - セッション状態のシリアライゼーション・デシリアライゼーション機能
31
+ - 音声生成進捗の追跡と復元機能
32
+ - **グローバルリソース管理**: VOICEVOX Coreマネージャーは全ユーザー間で共有
33
+ - **ファイル分離**: ユーザーごとに独立したtempおよびoutputディレクトリ構造
34
+
35
+ ### 状態管理パターン
36
+ - **Gradio State**: `gr.State()`を使用したセッション状態の管理
37
+ - **自動保存**: 設定変更時の自動セッション保存
38
+ - **復元処理**: アプリケーション開始時の既存セッション検出と復元
39
+ - **エラーハンドリング**: セッション復元失敗時の新規セッション作成
40
+
41
  ## フォルダ構成
42
  - yomitalk/ - メインアプリケーションコード
43
  - common/ - 共通データモデルおよび定義
 
52
  - utils/ - ユーティリティ関数
53
  - app.py - Gradioアプリ構築
54
  - prompt_manager.py - プロンプト管理および生成
55
+ - user_session.py - ユーザーセッション管理とステート永続化
56
  - templates/ - テンプレートファイル
57
  - common_podcast_utils.j2 - 共通のポッドキャスト生成ユーティリティ
58
  - paper_to_podcast.j2 - 論文解説用テンプレート
 
66
  - favicon.ico - ファビコン
67
  - data/ - 一時データ保存用
68
  - temp/ - アップロードされたファイルの一時保存
69
+ - {session_id}/ - ユーザーセッションごとの一時ディレクトリ
70
+ - session_state.json - セッション状態の永続化ファイル
71
+ - talks/ - 音声生成パーツの一時保存
72
  - output/ - 生成された音声ファイル
73
+ - {session_id}/ - ユーザーセッションごとの出力ディレクトリ
74
  - logs/ - ログファイル保存用
75
  - tests/ - テストコード
76
  - data/ - テスト用データ
 
109
  - OpenAI APIとGoogle Gemini APIの切り替え機能
110
  - 各APIのモデル選択とパラメータ調整機能
111
  - トークン使用状況の表示機能
112
+ 9. セッション状態の永続化
113
+ - ユーザーの設定やセッション状態の自動保存・復元機能
114
+ - ブラウザリフレッシュや接続断後の状態継続
115
+ - API キー以外の全設定(ドキュメントタイプ、モデル設定、キャラクター等)の保持
116
+ - 音声生成進捗状況の復元機能
117
 
118
  ## コーディング規則
119
  - PEP 8準拠のPythonコード
 
142
  - ユニットテストによる各コンポーネントの個別検証
143
  - テストファイルは `tests/unit/` ディレクトリに配置
144
  - 各クラス・モジュールごとに独立したテストファイルを作成
145
+ - セッション永続化機能のテスト(`test_session_persistence.py`)
146
  - モックを使用したAPIのテスト(OpenAI API、Gemini API)
147
  - テスト用のサンプルPDFおよびテキストデータを用意した自動テスト
148
  - GitHubワークフローによるCI自動実行
tests/unit/test_cleanup_old_sessions.py CHANGED
@@ -47,8 +47,8 @@ class TestSessionCleanup:
47
  user_session = UserSession("test_session_cleanup")
48
 
49
  # グローバル変数をパッチしてテスト用ディレクトリを使用
50
- with patch("yomitalk.session.BASE_TEMP_DIR", test_temp_dir), patch(
51
- "yomitalk.session.BASE_OUTPUT_DIR", test_output_dir
52
  ):
53
  # 現在の時刻を取得
54
  current_time = int(time.time())
 
47
  user_session = UserSession("test_session_cleanup")
48
 
49
  # グローバル変数をパッチしてテスト用ディレクトリを使用
50
+ with patch("yomitalk.user_session.BASE_TEMP_DIR", test_temp_dir), patch(
51
+ "yomitalk.user_session.BASE_OUTPUT_DIR", test_output_dir
52
  ):
53
  # 現在の時刻を取得
54
  current_time = int(time.time())
tests/unit/test_session_persistence.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test session persistence functionality."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import tempfile
7
+ from pathlib import Path
8
+ from unittest.mock import patch
9
+
10
+ import pytest
11
+
12
+ from yomitalk.common import APIType
13
+ from yomitalk.prompt_manager import DocumentType, PodcastMode
14
+ from yomitalk.user_session import UserSession
15
+
16
+
17
+ class TestSessionPersistence:
18
+ """Test session persistence functionality."""
19
+
20
+ @pytest.fixture
21
+ def temp_session_dir(self):
22
+ """Create a temporary directory for session testing."""
23
+ temp_dir = Path(tempfile.mkdtemp())
24
+ yield temp_dir
25
+ if temp_dir.exists():
26
+ shutil.rmtree(temp_dir)
27
+
28
+ def test_session_serialization_to_dict(self):
29
+ """Test session serialization to dictionary."""
30
+ session = UserSession("test_serialization")
31
+
32
+ # Configure some settings - set API key first to enable API type setting
33
+ session.text_processor.set_openai_api_key("test_key")
34
+ session.text_processor.set_api_type(APIType.OPENAI)
35
+ session.text_processor.set_document_type(DocumentType.PAPER)
36
+ session.text_processor.set_podcast_mode("section_by_section")
37
+ session.text_processor.openai_model.set_max_tokens(2000)
38
+ session.text_processor.openai_model.set_model_name("gpt-4.1-mini")
39
+
40
+ # Serialize to dict
41
+ session_dict = session.to_dict()
42
+
43
+ # Verify basic structure
44
+ assert "session_id" in session_dict
45
+ assert "audio_generation_state" in session_dict
46
+ assert "text_processor_state" in session_dict
47
+ assert "last_save_time" in session_dict
48
+
49
+ # Verify session ID
50
+ assert session_dict["session_id"] == "test_serialization"
51
+
52
+ # Verify text processor state
53
+ text_state = session_dict["text_processor_state"]
54
+ assert text_state["current_api_type"] == APIType.OPENAI.value
55
+ assert text_state["openai_max_tokens"] == 2000
56
+ assert text_state["openai_model_name"] == "gpt-4.1-mini"
57
+
58
+ # Verify prompt manager state
59
+ pm_state = text_state["prompt_manager_state"]
60
+ assert pm_state["current_document_type"] == DocumentType.PAPER.value
61
+ assert pm_state["current_mode"] == PodcastMode.SECTION_BY_SECTION.value
62
+
63
+ def test_session_deserialization_from_dict(self):
64
+ """Test session restoration from dictionary."""
65
+ # Create original session
66
+ original_session = UserSession("test_deserialization")
67
+ original_session.text_processor.set_gemini_api_key("test_key")
68
+ original_session.text_processor.set_api_type(APIType.GEMINI)
69
+ original_session.text_processor.set_document_type(DocumentType.MANUAL)
70
+ original_session.text_processor.set_podcast_mode("standard")
71
+ original_session.text_processor.gemini_model.set_max_tokens(1500)
72
+ original_session.text_processor.gemini_model.set_model_name(
73
+ "gemini-2.5-pro-preview-05-06"
74
+ )
75
+
76
+ # Serialize to dict
77
+ session_dict = original_session.to_dict()
78
+
79
+ # Restore from dict
80
+ restored_session = UserSession.from_dict(session_dict)
81
+
82
+ # Verify session was restored correctly
83
+ assert restored_session.session_id == "test_deserialization"
84
+ assert restored_session.text_processor.current_api_type == APIType.GEMINI
85
+ assert (
86
+ restored_session.text_processor.prompt_manager.current_document_type
87
+ == DocumentType.MANUAL
88
+ )
89
+ assert (
90
+ restored_session.text_processor.prompt_manager.current_mode
91
+ == PodcastMode.STANDARD
92
+ )
93
+ assert restored_session.text_processor.gemini_model.get_max_tokens() == 1500
94
+ assert (
95
+ restored_session.text_processor.gemini_model.model_name
96
+ == "gemini-2.5-pro-preview-05-06"
97
+ )
98
+
99
+ def test_session_file_save_and_load(self, temp_session_dir):
100
+ """Test session save/load to/from file."""
101
+ # Patch the base directories to use temp directory
102
+ with patch("yomitalk.user_session.BASE_TEMP_DIR", temp_session_dir):
103
+ # Create and configure session
104
+ session = UserSession("test_file_persistence")
105
+ session.text_processor.set_openai_api_key("test_key")
106
+ session.text_processor.set_api_type(APIType.OPENAI)
107
+ session.text_processor.set_document_type(DocumentType.BLOG)
108
+ session.text_processor.openai_model.set_max_tokens(3000)
109
+
110
+ # Update audio state
111
+ session.update_audio_generation_state(
112
+ status="generating", progress=0.5, current_script="Test script content"
113
+ )
114
+
115
+ # Save to file
116
+ success = session.save_to_file()
117
+ assert success is True
118
+
119
+ # Verify file was created
120
+ session_file = (
121
+ temp_session_dir / "test_file_persistence" / "session_state.json"
122
+ )
123
+ assert session_file.exists()
124
+
125
+ # Verify file content is valid JSON
126
+ with open(session_file, "r") as f:
127
+ saved_data = json.load(f)
128
+ assert saved_data["session_id"] == "test_file_persistence"
129
+
130
+ # Load session from file
131
+ loaded_session = UserSession.load_from_file("test_file_persistence")
132
+ assert loaded_session is not None
133
+ assert loaded_session.session_id == "test_file_persistence"
134
+ assert loaded_session.text_processor.current_api_type == APIType.OPENAI
135
+ assert (
136
+ loaded_session.text_processor.prompt_manager.current_document_type
137
+ == DocumentType.BLOG
138
+ )
139
+ assert loaded_session.text_processor.openai_model.get_max_tokens() == 3000
140
+
141
+ # Verify audio state was restored
142
+ audio_state = loaded_session.get_audio_generation_status()
143
+ assert audio_state["status"] == "generating"
144
+ assert audio_state["progress"] == 0.5
145
+ assert audio_state["current_script"] == "Test script content"
146
+
147
+ def test_session_load_nonexistent_file(self, temp_session_dir):
148
+ """Test loading a session that doesn't exist."""
149
+ with patch("yomitalk.user_session.BASE_TEMP_DIR", temp_session_dir):
150
+ loaded_session = UserSession.load_from_file("nonexistent_session")
151
+ assert loaded_session is None
152
+
153
+ def test_session_auto_save(self, temp_session_dir):
154
+ """Test automatic session saving."""
155
+ with patch("yomitalk.user_session.BASE_TEMP_DIR", temp_session_dir):
156
+ session = UserSession("test_auto_save")
157
+
158
+ # Auto-save should be triggered when creating new session
159
+ session_file = temp_session_dir / "test_auto_save" / "session_state.json"
160
+ # Note: auto_save is called in the creation process
161
+
162
+ # Trigger auto-save by updating state
163
+ session.update_audio_generation_state(status="completed")
164
+
165
+ # File should exist after auto-save
166
+ assert session_file.exists()
167
+
168
+ # Verify content was saved
169
+ with open(session_file, "r") as f:
170
+ saved_data = json.load(f)
171
+ assert saved_data["audio_generation_state"]["status"] == "completed"
172
+
173
+ def test_session_restoration_info(self):
174
+ """Test session restoration information."""
175
+ # Clear any environment API keys for this test
176
+ with patch.dict(
177
+ os.environ, {"OPENAI_API_KEY": "", "GOOGLE_API_KEY": ""}, clear=False
178
+ ):
179
+ session = UserSession("test_restoration_info")
180
+
181
+ # Get restoration info
182
+ info = session.get_session_restoration_info()
183
+
184
+ # Verify structure
185
+ assert "session_id" in info
186
+ assert "missing_api_keys" in info
187
+ assert "current_api_type" in info
188
+ assert "has_generated_audio" in info
189
+ assert "last_save_time" in info
190
+
191
+ # Verify values (initially no API keys or audio)
192
+ assert info["session_id"] == "test_restoration_info"
193
+ assert info["missing_api_keys"]["openai"] is True # No API key set
194
+ assert info["missing_api_keys"]["gemini"] is True # No API key set
195
+ assert info["has_generated_audio"] is False # No audio generated
196
+
197
+ # Set an API key and check that it's no longer missing
198
+ session.text_processor.set_openai_api_key("test_key")
199
+ session.text_processor.set_api_type(APIType.OPENAI)
200
+ info = session.get_session_restoration_info()
201
+ assert info["current_api_type"] == APIType.OPENAI.value
202
+ assert info["missing_api_keys"]["openai"] is False # Now has key
203
+ assert info["missing_api_keys"]["gemini"] is True # Still missing
204
+
205
+ def test_session_needs_api_key_restoration(self):
206
+ """Test API key restoration detection."""
207
+ # Clear any environment API keys for this test
208
+ with patch.dict(
209
+ os.environ, {"OPENAI_API_KEY": "", "GOOGLE_API_KEY": ""}, clear=False
210
+ ):
211
+ session = UserSession("test_api_key_restoration")
212
+
213
+ # Initially both API keys should be missing
214
+ missing_keys = session.needs_api_key_restoration()
215
+ assert missing_keys["openai"] is True
216
+ assert missing_keys["gemini"] is True
217
+
218
+ # Set OpenAI API key
219
+ session.text_processor.set_openai_api_key("test_openai_key")
220
+ missing_keys = session.needs_api_key_restoration()
221
+ assert missing_keys["openai"] is False # Now has key
222
+ assert missing_keys["gemini"] is True # Still missing
223
+
224
+ # Set Gemini API key
225
+ session.text_processor.set_gemini_api_key("test_gemini_key")
226
+ missing_keys = session.needs_api_key_restoration()
227
+ assert missing_keys["openai"] is False # Has key
228
+ assert missing_keys["gemini"] is False # Now has key
229
+
230
+ def test_session_character_mapping_persistence(self, temp_session_dir):
231
+ """Test character mapping persistence."""
232
+ with patch("yomitalk.user_session.BASE_TEMP_DIR", temp_session_dir):
233
+ # Create session and set character mapping
234
+ session = UserSession("test_character_mapping")
235
+ session.text_processor.set_character_mapping("ずんだもん", "四国めたん")
236
+
237
+ # Save and reload
238
+ session.save_to_file()
239
+ loaded_session = UserSession.load_from_file("test_character_mapping")
240
+
241
+ # Verify character mapping was preserved
242
+ assert loaded_session is not None
243
+ char1, char2 = loaded_session.current_character_mapping
244
+ assert char1 == "ずんだもん"
245
+ assert char2 == "四国めたん"
246
+
247
+ def test_session_roundtrip_persistence(self, temp_session_dir):
248
+ """Test complete roundtrip session persistence."""
249
+ with patch("yomitalk.user_session.BASE_TEMP_DIR", temp_session_dir):
250
+ # Create session with comprehensive settings
251
+ original_session = UserSession("test_roundtrip")
252
+
253
+ # Configure all major settings
254
+ original_session.text_processor.set_gemini_api_key("test_key")
255
+ original_session.text_processor.set_api_type(APIType.GEMINI)
256
+ original_session.text_processor.set_document_type(DocumentType.MINUTES)
257
+ original_session.text_processor.set_podcast_mode("section_by_section")
258
+ original_session.text_processor.set_character_mapping(
259
+ "九州そら", "中国うさぎ"
260
+ )
261
+ original_session.text_processor.openai_model.set_max_tokens(4000)
262
+ original_session.text_processor.openai_model.set_model_name("gpt-4.1")
263
+ original_session.text_processor.gemini_model.set_max_tokens(2500)
264
+ original_session.text_processor.gemini_model.set_model_name(
265
+ "gemini-2.5-pro-preview-05-06"
266
+ )
267
+
268
+ # Update audio generation state
269
+ original_session.update_audio_generation_state(
270
+ status="completed",
271
+ progress=1.0,
272
+ current_script="完全なスクリプト内容",
273
+ final_audio_path="/path/to/final/audio.wav",
274
+ )
275
+
276
+ # Save session
277
+ save_success = original_session.save_to_file()
278
+ assert save_success is True
279
+
280
+ # Load session
281
+ loaded_session = UserSession.load_from_file("test_roundtrip")
282
+ assert loaded_session is not None
283
+
284
+ # Verify all settings were preserved
285
+ assert loaded_session.text_processor.current_api_type == APIType.GEMINI
286
+ assert (
287
+ loaded_session.text_processor.prompt_manager.current_document_type
288
+ == DocumentType.MINUTES
289
+ )
290
+ assert (
291
+ loaded_session.text_processor.prompt_manager.current_mode
292
+ == PodcastMode.SECTION_BY_SECTION
293
+ )
294
+
295
+ char1, char2 = loaded_session.current_character_mapping
296
+ assert char1 == "九州そら"
297
+ assert char2 == "中国うさぎ"
298
+
299
+ assert loaded_session.text_processor.openai_model.get_max_tokens() == 4000
300
+ assert loaded_session.text_processor.openai_model.model_name == "gpt-4.1"
301
+ assert loaded_session.text_processor.gemini_model.get_max_tokens() == 2500
302
+ assert (
303
+ loaded_session.text_processor.gemini_model.model_name
304
+ == "gemini-2.5-pro-preview-05-06"
305
+ )
306
+
307
+ # Verify audio state was preserved
308
+ audio_state = loaded_session.get_audio_generation_status()
309
+ assert audio_state["status"] == "completed"
310
+ assert audio_state["progress"] == 1.0
311
+ assert audio_state["current_script"] == "完全なスクリプト内容"
312
+ assert audio_state["final_audio_path"] == "/path/to/final/audio.wav"
313
+
314
+ # Verify restoration info shows correct state
315
+ restoration_info = loaded_session.get_session_restoration_info()
316
+ assert restoration_info["current_api_type"] == APIType.GEMINI.value
317
+ assert restoration_info["has_generated_audio"] is True
318
+ # Note: API keys are not persisted for security, but the test environment might have them
319
+ # So we don't assert their absence in this comprehensive test
tests/unit/test_text_processor.py CHANGED
@@ -70,9 +70,14 @@ class TestTextProcessor:
70
 
71
  def test_set_api_type(self):
72
  """Test setting API type."""
73
- # APIが設定されていない場合
74
- assert self.text_processor.set_api_type(APIType.OPENAI) is False
75
- assert self.text_processor.set_api_type(APIType.GEMINI) is False
 
 
 
 
 
76
 
77
  # APIが設定されている場合をシミュレート
78
  with patch.object(
 
70
 
71
  def test_set_api_type(self):
72
  """Test setting API type."""
73
+ # Clear environment API keys and APIが設定されていない場合
74
+ with patch.object(
75
+ self.text_processor.openai_model, "has_api_key", return_value=False
76
+ ), patch.object(
77
+ self.text_processor.gemini_model, "has_api_key", return_value=False
78
+ ):
79
+ assert self.text_processor.set_api_type(APIType.OPENAI) is False
80
+ assert self.text_processor.set_api_type(APIType.GEMINI) is False
81
 
82
  # APIが設定されている場合をシミュレート
83
  with patch.object(
yomitalk/app.py CHANGED
@@ -50,9 +50,20 @@ class PaperPodcastApp:
50
  dummy_session.cleanup_old_sessions()
51
 
52
  def create_user_session(self, request: gr.Request) -> UserSession:
53
- """Create a new user session with unique session ID."""
54
  session_id = request.session_hash
55
- return UserSession(session_id)
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  def clear_extracted_text(self) -> str:
58
  """Clear the extracted text area."""
@@ -68,6 +79,7 @@ class PaperPodcastApp:
68
  logger.debug(
69
  f"OpenAI API key set for session {user_session.session_id}: {success}"
70
  )
 
71
  return user_session
72
 
73
  def set_gemini_api_key(self, api_key: str, user_session: UserSession):
@@ -80,6 +92,7 @@ class PaperPodcastApp:
80
  logger.debug(
81
  f"Gemini API key set for session {user_session.session_id}: {success}"
82
  )
 
83
  return user_session
84
 
85
  def switch_llm_type(
@@ -95,6 +108,7 @@ class PaperPodcastApp:
95
  logger.debug(
96
  f"{api_type.display_name} API key not set for session {user_session.session_id}"
97
  )
 
98
  return user_session
99
 
100
  def extract_file_text(
@@ -1154,6 +1168,7 @@ class PaperPodcastApp:
1154
  """
1155
  success = user_session.text_processor.openai_model.set_model_name(model_name)
1156
  logger.debug(f"OpenAI model set to {model_name}: {success}")
 
1157
  return user_session
1158
 
1159
  def set_gemini_model_name(
@@ -1167,6 +1182,7 @@ class PaperPodcastApp:
1167
  """
1168
  success = user_session.text_processor.gemini_model.set_model_name(model_name)
1169
  logger.debug(f"Gemini model set to {model_name}: {success}")
 
1170
  return user_session
1171
 
1172
  def get_openai_max_tokens(self, user_session: UserSession) -> int:
@@ -1198,6 +1214,7 @@ class PaperPodcastApp:
1198
  """
1199
  success = user_session.text_processor.openai_model.set_max_tokens(max_tokens)
1200
  logger.debug(f"OpenAI max tokens set to {max_tokens}: {success}")
 
1201
  return user_session
1202
 
1203
  def set_gemini_max_tokens(
@@ -1211,6 +1228,7 @@ class PaperPodcastApp:
1211
  """
1212
  success = user_session.text_processor.gemini_model.set_max_tokens(max_tokens)
1213
  logger.debug(f"Gemini max tokens set to {max_tokens}: {success}")
 
1214
  return user_session
1215
 
1216
  def get_available_characters(self) -> List[str]:
@@ -1234,6 +1252,7 @@ class PaperPodcastApp:
1234
  character1, character2
1235
  )
1236
  logger.debug(f"Character mapping set: {character1}, {character2}: {success}")
 
1237
  return user_session
1238
 
1239
  def update_process_button_state(
@@ -1287,6 +1306,7 @@ class PaperPodcastApp:
1287
  success = user_session.text_processor.set_podcast_mode(podcast_mode.value)
1288
 
1289
  logger.debug(f"Podcast mode set to {mode}: {success}")
 
1290
 
1291
  except ValueError as e:
1292
  logger.error(f"Error setting podcast mode: {str(e)}")
@@ -1379,6 +1399,7 @@ class PaperPodcastApp:
1379
  success = user_session.text_processor.set_document_type(document_type)
1380
 
1381
  logger.debug(f"Document type set to {doc_type}: {success}")
 
1382
 
1383
  except ValueError as e:
1384
  logger.error(f"Error setting document type: {str(e)}")
 
50
  dummy_session.cleanup_old_sessions()
51
 
52
  def create_user_session(self, request: gr.Request) -> UserSession:
53
+ """Create a new user session with unique session ID or restore from saved state."""
54
  session_id = request.session_hash
55
+
56
+ # Try to load existing session state first
57
+ existing_session = UserSession.load_from_file(session_id)
58
+ if existing_session:
59
+ logger.info(f"Restored existing session: {session_id}")
60
+ return existing_session
61
+
62
+ # Create new session if no saved state found
63
+ logger.info(f"Created new session: {session_id}")
64
+ new_session = UserSession(session_id)
65
+ new_session.auto_save() # Save initial state
66
+ return new_session
67
 
68
  def clear_extracted_text(self) -> str:
69
  """Clear the extracted text area."""
 
79
  logger.debug(
80
  f"OpenAI API key set for session {user_session.session_id}: {success}"
81
  )
82
+ user_session.auto_save() # Save session state after API key change
83
  return user_session
84
 
85
  def set_gemini_api_key(self, api_key: str, user_session: UserSession):
 
92
  logger.debug(
93
  f"Gemini API key set for session {user_session.session_id}: {success}"
94
  )
95
+ user_session.auto_save() # Save session state after API key change
96
  return user_session
97
 
98
  def switch_llm_type(
 
108
  logger.debug(
109
  f"{api_type.display_name} API key not set for session {user_session.session_id}"
110
  )
111
+ user_session.auto_save() # Save session state after API type change
112
  return user_session
113
 
114
  def extract_file_text(
 
1168
  """
1169
  success = user_session.text_processor.openai_model.set_model_name(model_name)
1170
  logger.debug(f"OpenAI model set to {model_name}: {success}")
1171
+ user_session.auto_save() # Save session state after model name change
1172
  return user_session
1173
 
1174
  def set_gemini_model_name(
 
1182
  """
1183
  success = user_session.text_processor.gemini_model.set_model_name(model_name)
1184
  logger.debug(f"Gemini model set to {model_name}: {success}")
1185
+ user_session.auto_save() # Save session state after model name change
1186
  return user_session
1187
 
1188
  def get_openai_max_tokens(self, user_session: UserSession) -> int:
 
1214
  """
1215
  success = user_session.text_processor.openai_model.set_max_tokens(max_tokens)
1216
  logger.debug(f"OpenAI max tokens set to {max_tokens}: {success}")
1217
+ user_session.auto_save() # Save session state after max tokens change
1218
  return user_session
1219
 
1220
  def set_gemini_max_tokens(
 
1228
  """
1229
  success = user_session.text_processor.gemini_model.set_max_tokens(max_tokens)
1230
  logger.debug(f"Gemini max tokens set to {max_tokens}: {success}")
1231
+ user_session.auto_save() # Save session state after max tokens change
1232
  return user_session
1233
 
1234
  def get_available_characters(self) -> List[str]:
 
1252
  character1, character2
1253
  )
1254
  logger.debug(f"Character mapping set: {character1}, {character2}: {success}")
1255
+ user_session.auto_save() # Save session state after character mapping change
1256
  return user_session
1257
 
1258
  def update_process_button_state(
 
1306
  success = user_session.text_processor.set_podcast_mode(podcast_mode.value)
1307
 
1308
  logger.debug(f"Podcast mode set to {mode}: {success}")
1309
+ user_session.auto_save() # Save session state after podcast mode change
1310
 
1311
  except ValueError as e:
1312
  logger.error(f"Error setting podcast mode: {str(e)}")
 
1399
  success = user_session.text_processor.set_document_type(document_type)
1400
 
1401
  logger.debug(f"Document type set to {doc_type}: {success}")
1402
+ user_session.auto_save() # Save session state after document type change
1403
 
1404
  except ValueError as e:
1405
  logger.error(f"Error setting document type: {str(e)}")
yomitalk/user_session.py CHANGED
@@ -3,15 +3,17 @@
3
  This module contains the UserSession class for managing per-user session data.
4
  """
5
 
 
6
  import re
7
  import shutil
8
  import time
9
  from pathlib import Path
10
- from typing import Any, Dict, Tuple
11
 
12
  from yomitalk.common import APIType
13
  from yomitalk.components.audio_generator import AudioGenerator
14
  from yomitalk.components.text_processor import TextProcessor
 
15
  from yomitalk.utils.logger import logger
16
 
17
  # Global base directories for all users
@@ -349,6 +351,9 @@ class UserSession:
349
  # Update last update time
350
  self.audio_generation_state["last_update"] = time.time()
351
 
 
 
 
352
  def reset_audio_generation_state(self) -> None:
353
  """Reset audio generation state to initial values."""
354
  self.audio_generation_state = {
@@ -365,6 +370,9 @@ class UserSession:
365
  }
366
  logger.debug("Audio generation state reset")
367
 
 
 
 
368
  def is_audio_generation_active(self) -> bool:
369
  """Check if audio generation is currently active.
370
 
@@ -394,3 +402,183 @@ class UserSession:
394
  self.audio_generation_state["final_audio_path"] is not None
395
  or len(list(self.audio_generation_state["streaming_parts"])) > 0
396
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  This module contains the UserSession class for managing per-user session data.
4
  """
5
 
6
+ import json
7
  import re
8
  import shutil
9
  import time
10
  from pathlib import Path
11
+ from typing import Any, Dict, Optional, Tuple
12
 
13
  from yomitalk.common import APIType
14
  from yomitalk.components.audio_generator import AudioGenerator
15
  from yomitalk.components.text_processor import TextProcessor
16
+ from yomitalk.prompt_manager import DocumentType, PodcastMode
17
  from yomitalk.utils.logger import logger
18
 
19
  # Global base directories for all users
 
351
  # Update last update time
352
  self.audio_generation_state["last_update"] = time.time()
353
 
354
+ # Auto-save session state
355
+ self.auto_save()
356
+
357
  def reset_audio_generation_state(self) -> None:
358
  """Reset audio generation state to initial values."""
359
  self.audio_generation_state = {
 
370
  }
371
  logger.debug("Audio generation state reset")
372
 
373
+ # Auto-save session state
374
+ self.auto_save()
375
+
376
  def is_audio_generation_active(self) -> bool:
377
  """Check if audio generation is currently active.
378
 
 
402
  self.audio_generation_state["final_audio_path"] is not None
403
  or len(list(self.audio_generation_state["streaming_parts"])) > 0
404
  )
405
+
406
+ def to_dict(self) -> Dict[str, Any]:
407
+ """Serialize session state to dictionary for persistence.
408
+
409
+ Returns:
410
+ Dict[str, Any]: Serializable session state
411
+ """
412
+ return {
413
+ "session_id": self.session_id,
414
+ "audio_generation_state": self.audio_generation_state.copy(),
415
+ "text_processor_state": {
416
+ "current_api_type": (
417
+ self.text_processor.current_api_type.value
418
+ if self.text_processor.current_api_type
419
+ else None
420
+ ),
421
+ "openai_api_key_set": bool(self.text_processor.openai_model.api_key),
422
+ "gemini_api_key_set": bool(self.text_processor.gemini_model.api_key),
423
+ "openai_max_tokens": self.text_processor.openai_model.get_max_tokens(),
424
+ "gemini_max_tokens": self.text_processor.gemini_model.get_max_tokens(),
425
+ "openai_model_name": self.text_processor.openai_model.model_name,
426
+ "gemini_model_name": self.text_processor.gemini_model.model_name,
427
+ "prompt_manager_state": {
428
+ "current_document_type": self.text_processor.prompt_manager.current_document_type.value,
429
+ "current_mode": self.text_processor.prompt_manager.current_mode.value,
430
+ "char_mapping": self.text_processor.prompt_manager.char_mapping.copy(),
431
+ },
432
+ },
433
+ "last_save_time": time.time(),
434
+ }
435
+
436
+ @classmethod
437
+ def from_dict(cls, data: Dict[str, Any]) -> "UserSession":
438
+ """Restore session state from dictionary.
439
+
440
+ Args:
441
+ data: Serialized session state
442
+
443
+ Returns:
444
+ UserSession: Restored session instance
445
+ """
446
+ session = cls(data["session_id"])
447
+
448
+ # Restore audio generation state
449
+ if "audio_generation_state" in data:
450
+ session.audio_generation_state.update(data["audio_generation_state"])
451
+
452
+ # Restore text processor state
453
+ if "text_processor_state" in data:
454
+ text_state = data["text_processor_state"]
455
+
456
+ # Restore API type
457
+ if text_state.get("current_api_type"):
458
+ # Find APIType by value and set directly (bypass API key validation)
459
+ for api_type in APIType:
460
+ if api_type.value == text_state["current_api_type"]:
461
+ session.text_processor.current_api_type = api_type
462
+ break
463
+
464
+ # Restore model settings
465
+ if "openai_max_tokens" in text_state:
466
+ session.text_processor.openai_model.set_max_tokens(
467
+ text_state["openai_max_tokens"]
468
+ )
469
+ if "gemini_max_tokens" in text_state:
470
+ session.text_processor.gemini_model.set_max_tokens(
471
+ text_state["gemini_max_tokens"]
472
+ )
473
+ if "openai_model_name" in text_state:
474
+ session.text_processor.openai_model.set_model_name(
475
+ text_state["openai_model_name"]
476
+ )
477
+ if "gemini_model_name" in text_state:
478
+ session.text_processor.gemini_model.set_model_name(
479
+ text_state["gemini_model_name"]
480
+ )
481
+
482
+ # Restore prompt manager state
483
+ if "prompt_manager_state" in text_state:
484
+ pm_state = text_state["prompt_manager_state"]
485
+ if "current_document_type" in pm_state:
486
+ # Find DocumentType by value
487
+ for doc_type in DocumentType:
488
+ if doc_type.value == pm_state["current_document_type"]:
489
+ session.text_processor.prompt_manager.set_document_type(
490
+ doc_type
491
+ )
492
+ break
493
+ if "current_mode" in pm_state:
494
+ # Find PodcastMode by value
495
+ for mode in PodcastMode:
496
+ if mode.value == pm_state["current_mode"]:
497
+ session.text_processor.prompt_manager.set_podcast_mode(mode)
498
+ break
499
+ if "char_mapping" in pm_state:
500
+ session.text_processor.prompt_manager.char_mapping = pm_state[
501
+ "char_mapping"
502
+ ].copy()
503
+
504
+ logger.info(f"Session restored from saved state: {session.session_id}")
505
+ return session
506
+
507
+ def save_to_file(self) -> bool:
508
+ """Save session state to file.
509
+
510
+ Returns:
511
+ bool: True if save was successful
512
+ """
513
+ try:
514
+ session_file = self.get_temp_dir() / "session_state.json"
515
+ with open(session_file, "w", encoding="utf-8") as f:
516
+ json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
517
+ logger.debug(f"Session state saved to file: {session_file}")
518
+ return True
519
+ except Exception as e:
520
+ logger.error(f"Failed to save session state: {str(e)}")
521
+ return False
522
+
523
+ @classmethod
524
+ def load_from_file(cls, session_id: str) -> Optional["UserSession"]:
525
+ """Load session state from file.
526
+
527
+ Args:
528
+ session_id: Session ID to load
529
+
530
+ Returns:
531
+ UserSession: Restored session or None if not found
532
+ """
533
+ try:
534
+ session_file = BASE_TEMP_DIR / session_id / "session_state.json"
535
+ if not session_file.exists():
536
+ logger.debug(f"No saved session state found: {session_file}")
537
+ return None
538
+
539
+ with open(session_file, "r", encoding="utf-8") as f:
540
+ data = json.load(f)
541
+
542
+ session = cls.from_dict(data)
543
+ logger.info(f"Session state loaded from file: {session_file}")
544
+ return session
545
+ except Exception as e:
546
+ logger.error(f"Failed to load session state: {str(e)}")
547
+ return None
548
+
549
+ def auto_save(self) -> None:
550
+ """Automatically save session state if significant changes occurred."""
551
+ try:
552
+ self.save_to_file()
553
+ except Exception as e:
554
+ logger.error(f"Auto-save failed for session {self.session_id}: {str(e)}")
555
+
556
+ def needs_api_key_restoration(self) -> Dict[str, bool]:
557
+ """Check which API keys need to be restored after session reload.
558
+
559
+ Returns:
560
+ Dict[str, bool]: Dictionary indicating which API keys are missing
561
+ """
562
+ return {
563
+ "openai": not self.text_processor.openai_model.has_api_key(),
564
+ "gemini": not self.text_processor.gemini_model.has_api_key(),
565
+ }
566
+
567
+ def get_session_restoration_info(self) -> Dict[str, Any]:
568
+ """Get information about session restoration status.
569
+
570
+ Returns:
571
+ Dict[str, Any]: Session restoration information
572
+ """
573
+ missing_keys = self.needs_api_key_restoration()
574
+ return {
575
+ "session_id": self.session_id,
576
+ "missing_api_keys": missing_keys,
577
+ "current_api_type": (
578
+ self.text_processor.current_api_type.value
579
+ if self.text_processor.current_api_type
580
+ else None
581
+ ),
582
+ "has_generated_audio": self.has_generated_audio(),
583
+ "last_save_time": time.time(),
584
+ }