Skip to content

Commit dbb98d0

Browse files
web-flowclaude
andcommitted
refactor: Phase 5-6 完了 - テスト修正 + LEVEL_CONFIG統合
Phase 5: 既存テスト失敗24件を修正 - dir_name → dir キー名修正 - _shadow_io → _io 属性名修正 - threshold期待値をDEFAULT_THRESHOLDSに合わせて修正 - Hypothesisフィクスチャスコープ警告を抑制 Phase 6: LEVEL_CONFIGに閾値を統合(Single Source of Truth) - LEVEL_CONFIGにthresholdキーを追加 - DEFAULT_THRESHOLDSを削除 - threshold_provider.pyをLEVEL_CONFIG参照に変更 - 関連テストを全て更新 結果: 1321 passed, 3 skipped (Windows権限テストのみ) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 9a630a1 commit dbb98d0

45 files changed

Lines changed: 2499 additions & 1157 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# 設計判断記録
2+
3+
本ドキュメントは、EpisodicRAGプロジェクトにおける主要な設計判断とその根拠を記録する。
4+
エンタープライズPython開発の教材として、各判断の「なぜ」を明示することを目的とする。
5+
6+
---
7+
8+
## アーキテクチャ決定
9+
10+
| 決定事項 | 検討した代替案 | 選択理由 |
11+
|---------|--------------|---------|
12+
| Clean Architecture 4層 | MVC, 単純なLayered | テスタビリティ、依存方向の制御 |
13+
| Strategy (LevelBehavior) | 継承階層, Switch文 | OCP準拠、新レベル追加時の変更最小化 |
14+
| Thin Facade (DigestConfig) | 直接アクセス, Thick Facade | シンプル化、重複排除、カプセル化 |
15+
| TypedDict | dataclass, NamedTuple | JSON互換性、段階的型付け、既存dictとの相互運用 |
16+
| Singleton (Registry) | DIコンテナ, グローバル変数 | ドメイン層のシンプルさ、テスト時のリセット可能性 |
17+
| Composite (ErrorFormatter) | 単一クラス, 関数群 | カテゴリ別責務分離、SRP準拠 |
18+
| Chain of Responsibility (TemplateLoader) | if-else連鎖, Switch | 戦略の追加・削除が容易、OCP準拠 |
19+
20+
---
21+
22+
## 各パターンの実装箇所
23+
24+
### Strategy Pattern
25+
- **実装**: `domain/level_registry.py`
26+
- **目的**: 8階層(weekly〜centurial)ごとの振る舞いを交換可能に
27+
- **SOLID**: OCP(新レベル追加時に既存コード変更不要)
28+
29+
### Facade Pattern
30+
- **実装**: `config/facade.py`
31+
- **目的**: 複雑な設定サブシステムへのシンプルなインターフェース
32+
- **設計判断**: Thin Facade(プロバイダを直接公開、重複プロパティ排除)
33+
34+
### Repository Pattern
35+
- **実装**: `infrastructure/json_repository/`
36+
- **目的**: ファイルI/Oの抽象化、ビジネスロジックからの分離
37+
- **SOLID**: DIP(上位層がI/O詳細に依存しない)
38+
39+
### Template Method Pattern
40+
- **実装**: `domain/error_formatter/base.py`
41+
- **目的**: パス正規化などの共通処理を基底クラスで定義
42+
- **SOLID**: DRY(コード重複の排除)
43+
44+
### Composite Pattern
45+
- **実装**: `domain/error_formatter/__init__.py`
46+
- **目的**: カテゴリ別フォーマッタを統合インターフェースで提供
47+
- **SOLID**: SRP(各フォーマッタが単一カテゴリに責任)
48+
49+
### Chain of Responsibility Pattern
50+
- **実装**: `infrastructure/json_repository/template_loader.py`
51+
- **目的**: テンプレートロード戦略の順次試行
52+
- **SOLID**: OCP(新戦略追加が容易)
53+
54+
---
55+
56+
## SOLID原則の実践箇所
57+
58+
### Single Responsibility Principle (SRP)
59+
- `domain/error_formatter/`: エラーカテゴリごとに独立クラス
60+
- `infrastructure/json_repository/`: I/O、テンプレート、ユーティリティを分離
61+
- `config/`: 各プロバイダが単一責務(閾値、パス、ソース)
62+
63+
### Open/Closed Principle (OCP)
64+
- `domain/level_registry.py`: 新レベル追加時に既存コード変更不要
65+
- `infrastructure/json_repository/template_loader.py`: 新戦略追加が容易
66+
67+
### Liskov Substitution Principle (LSP)
68+
- `domain/error_formatter/base.py`: 全サブクラスが基底クラスの契約を満たす
69+
- `infrastructure/json_repository/template_loader.py`: 全戦略がLoadStrategyを満たす
70+
71+
### Interface Segregation Principle (ISP)
72+
- `domain/protocols.py`: 必要最小限のProtocol定義
73+
- 各層の`__init__.py`: 必要なAPIのみをexport
74+
75+
### Dependency Inversion Principle (DIP)
76+
- `interfaces/finalize_from_shadow.py`: コンストラクタインジェクション
77+
- 全層: 上位層は下位層の具象に依存しない
78+
79+
---
80+
81+
## レイヤー構造
82+
83+
```
84+
┌─────────────────────────────────────────────────────────┐
85+
│ Interfaces Layer │
86+
│ (finalize_from_shadow.py, save_provisional_digest.py) │
87+
│ 外部からのエントリーポイント │
88+
└─────────────────────────────────────────────────────────┘
89+
↓ 依存
90+
┌─────────────────────────────────────────────────────────┐
91+
│ Application Layer │
92+
│ (shadow/, grand/, finalize/, tracking/) │
93+
│ ユースケースの実装、ビジネスプロセスの調整 │
94+
└─────────────────────────────────────────────────────────┘
95+
↓ 依存
96+
┌─────────────────────────────────────────────────────────┐
97+
│ Infrastructure Layer │
98+
│ (json_repository/, file_scanner.py, logging_config.py) │
99+
│ 外部リソースへのアクセス(ファイル、ログ) │
100+
└─────────────────────────────────────────────────────────┘
101+
↓ 依存
102+
┌─────────────────────────────────────────────────────────┐
103+
│ Config Layer │
104+
│ (facade.py, threshold_provider.py, path_resolver.py) │
105+
│ 設定管理、パス解決 │
106+
└─────────────────────────────────────────────────────────┘
107+
↓ 依存
108+
┌─────────────────────────────────────────────────────────┐
109+
│ Domain Layer │
110+
│ (types.py, constants.py, exceptions.py, protocols.py) │
111+
│ ビジネスルール、型定義、例外(外部依存なし) │
112+
└─────────────────────────────────────────────────────────┘
113+
```
114+
115+
**重要な制約**: 依存は常に下方向のみ。上位層が下位層に依存し、逆は許可されない。
116+
117+
---
118+
119+
## TypedDict vs dataclass の選択
120+
121+
| 観点 | TypedDict | dataclass |
122+
|-----|-----------|-----------|
123+
| JSON互換性 | ネイティブ対応 | 変換が必要 |
124+
| 既存dictとの相互運用 | シームレス | 明示的変換 |
125+
| 型チェック | 静的のみ | 静的+実行時 |
126+
| イミュータビリティ | 制御不可 | frozen=True |
127+
| デフォルト値 | total=Falseで対応 | 直接サポート |
128+
129+
**選択理由**: EpisodicRAGはJSONファイルを多用するため、TypedDictの方が自然。
130+
131+
---
132+
133+
## Singleton vs DI の選択
134+
135+
| 観点 | Singleton | Dependency Injection |
136+
|-----|-----------|---------------------|
137+
| シンプルさ || 中〜低 |
138+
| テスタビリティ | reset関数で対応 ||
139+
| グローバル状態 | あり | なし |
140+
| 設定変更 | 難しい | 容易 |
141+
142+
**選択理由**: ドメイン層は外部依存を持たないため、シンプルなSingletonで十分。
143+
テスト用に`reset_level_registry()`を提供してテスタビリティを確保。
144+
145+
---
146+
147+
## 参考リンク
148+
149+
- [Clean Architecture (Robert C. Martin)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
150+
- [SOLID Principles](https://en.wikipedia.org/wiki/SOLID)
151+
- [Design Patterns (GoF)](https://en.wikipedia.org/wiki/Design_Patterns)
152+
- [Python typing.TypedDict](https://docs.python.org/3/library/typing.html#typing.TypedDict)

EpisodicRAG/scripts/application/finalize/persistence.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ def save_regular_digest(
9797
save_json(final_path, as_dict(regular_digest))
9898
except IOError as e:
9999
formatter = get_error_formatter()
100-
raise FileIOError(formatter.file_io_error("save", final_path, e))
100+
raise FileIOError(formatter.file.file_io_error("save", final_path, e))
101101

102102
log_info(f"RegularDigest saved: {final_path}")
103103
return final_path
@@ -120,7 +120,7 @@ def update_grand_digest(
120120
overall_digest = regular_digest.get("overall_digest")
121121
if not overall_digest or not is_valid_dict(overall_digest):
122122
formatter = get_error_formatter()
123-
raise DigestError(formatter.validation_error("RegularDigest", "has no valid overall_digest", None))
123+
raise DigestError(formatter.validation.validation_error("RegularDigest", "has no valid overall_digest", None))
124124
# GrandDigestManager.update_digestは例外を投げる(失敗時)
125125
self.grand_digest_manager.update_digest(level, new_digest_name, overall_digest)
126126

EpisodicRAG/scripts/application/finalize/provisional_loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ def _load_provisional(self, provisional_path: Path) -> Tuple[List[IndividualDige
8787

8888
if not is_valid_dict(provisional_data):
8989
formatter = get_error_formatter()
90-
raise DigestError(formatter.invalid_type(provisional_path.name, "dict", provisional_data))
90+
raise DigestError(formatter.validation.invalid_type(provisional_path.name, "dict", provisional_data))
9191

9292
individual_digests = provisional_data.get("individual_digests", [])
9393
log_debug(f"{LOG_PREFIX_STATE} loaded_digests_count: {len(individual_digests)}")

EpisodicRAG/scripts/application/finalize/shadow_validator.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,19 @@ def _collect_validation_errors(
5656
# 型チェック
5757
formatter = get_error_formatter()
5858
if not is_valid_list(source_files):
59-
fatal_errors.append(formatter.invalid_type("source_files", "list", source_files))
59+
fatal_errors.append(formatter.validation.invalid_type("source_files", "list", source_files))
6060
return fatal_errors, warnings, numbers
6161

6262
# 空チェック
6363
if not source_files:
64-
fatal_errors.append(formatter.empty_collection(f"Shadow digest for level '{level}'"))
64+
fatal_errors.append(formatter.validation.empty_collection(f"Shadow digest for level '{level}'"))
6565
return fatal_errors, warnings, numbers
6666

6767
# ファイル名検証と番号抽出を1ループで実行
6868
for i, filename in enumerate(source_files):
6969
if not isinstance(filename, str):
7070
fatal_errors.append(
71-
formatter.invalid_type(f"filename at index {i}", "str", filename)
71+
formatter.validation.invalid_type(f"filename at index {i}", "str", filename)
7272
)
7373
continue
7474

@@ -142,7 +142,7 @@ def _validate_title(self, weave_title: str) -> None:
142142
"""
143143
if not weave_title or not weave_title.strip():
144144
formatter = get_error_formatter()
145-
raise ValidationError(formatter.empty_collection("weave_title"))
145+
raise ValidationError(formatter.validation.empty_collection("weave_title"))
146146

147147
def _fetch_shadow_digest(self, level: str) -> OverallDigestData:
148148
"""
@@ -162,7 +162,7 @@ def _fetch_shadow_digest(self, level: str) -> OverallDigestData:
162162
if shadow_digest is None:
163163
log_info("Run 'python shadow_grand_digest.py' to update shadow first")
164164
formatter = get_error_formatter()
165-
raise DigestError(formatter.digest_not_found(level, "shadow"))
165+
raise DigestError(formatter.digest.digest_not_found(level, "shadow"))
166166

167167
return shadow_digest
168168

@@ -178,7 +178,7 @@ def _validate_shadow_format(self, shadow_digest: Any) -> None:
178178
"""
179179
if not is_valid_dict(shadow_digest):
180180
formatter = get_error_formatter()
181-
raise ValidationError(formatter.invalid_type("shadow digest", "dict", shadow_digest))
181+
raise ValidationError(formatter.validation.invalid_type("shadow digest", "dict", shadow_digest))
182182

183183
def validate_and_get_shadow(self, level: str, weave_title: str) -> OverallDigestData:
184184
"""

EpisodicRAG/scripts/application/grand/grand_digest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,16 @@ def update_digest(
146146
formatter = get_error_formatter()
147147
# 型チェック
148148
if not is_valid_dict(grand_data):
149-
raise DigestError(formatter.invalid_type("GrandDigest.txt", "dict", grand_data))
149+
raise DigestError(formatter.validation.invalid_type("GrandDigest.txt", "dict", grand_data))
150150

151151
if "major_digests" not in grand_data:
152-
raise DigestError(formatter.config_section_missing("major_digests"))
152+
raise DigestError(formatter.config.config_section_missing("major_digests"))
153153

154154
available_levels = list(grand_data["major_digests"].keys())
155155
log_debug(f"{LOG_PREFIX_VALIDATE} available_levels: {available_levels}")
156156

157157
if level not in grand_data["major_digests"]:
158-
raise DigestError(formatter.unknown_level(level))
158+
raise DigestError(formatter.config.unknown_level(level))
159159

160160
# overall_digestを更新(完全なオブジェクトとして保存)
161161
log_debug(f"{LOG_PREFIX_STATE} updating overall_digest for level={level}")

EpisodicRAG/scripts/application/validators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,13 @@ def validate_source_files(files: Any, context: str = "source_files") -> List[str
114114
"""
115115
formatter = get_error_formatter()
116116
if files is None:
117-
raise ValidationError(formatter.validation_error(context, "cannot be None", None))
117+
raise ValidationError(formatter.validation.validation_error(context, "cannot be None", None))
118118

119119
if not isinstance(files, list):
120-
raise ValidationError(formatter.invalid_type(context, "list", files))
120+
raise ValidationError(formatter.validation.invalid_type(context, "list", files))
121121

122122
if not files:
123-
raise ValidationError(formatter.empty_collection(context))
123+
raise ValidationError(formatter.validation.empty_collection(context))
124124

125125
return files
126126

EpisodicRAG/scripts/config/__init__.py

Lines changed: 45 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,27 @@
55
66
Plugin自己完結版:Plugin内の.claude-plugin/config.jsonから設定を読み込む
77
8-
Architecture:
9-
DigestConfig は薄い Facade として機能し、以下のコンポーネントに委譲:
10-
- ConfigLoader: 設定ファイルの読み込み
11-
- PathResolver: パス解決
12-
- ThresholdProvider: 閾値管理
13-
- LevelPathService: レベル別パス管理
14-
- SourcePathResolver: ソースパス解決
15-
- ConfigValidator: 設定とディレクトリ構造の検証
8+
## 設計意図
9+
10+
ARCHITECTURE: Thin Facade Pattern
11+
DigestConfigは薄いFacadeとして機能し、内部コンポーネントに委譲。
12+
各コンポーネントは単一責任(SRP)を持つ。
13+
14+
| コンポーネント | 責務 |
15+
|---------------|------|
16+
| ConfigLoader | 設定ファイルの読み込み |
17+
| PathResolver | パス解決 |
18+
| ThresholdProvider | 閾値管理 |
19+
| LevelPathService | レベル別パス管理 |
20+
| SourcePathResolver | ソースパス解決 |
21+
| ConfigValidator | 設定とディレクトリ構造の検証 |
22+
23+
## 閾値アクセス方法
24+
25+
ARCHITECTURE: コンポーネント公開による責務明確化
26+
閾値は`threshold`プロパティ経由でアクセス:
27+
config.threshold.get_threshold("weekly")
28+
config.threshold.weekly_threshold
1629
1730
Usage:
1831
from config import DigestConfig
@@ -112,7 +125,7 @@ def __init__(self, plugin_root: Optional[Path] = None):
112125

113126
except (PermissionError, OSError) as e:
114127
formatter = get_error_formatter()
115-
raise ConfigError(formatter.initialization_failed("configuration", e)) from e
128+
raise ConfigError(formatter.config.initialization_failed("configuration", e)) from e
116129

117130
# =========================================================================
118131
# Context Manager Support
@@ -256,53 +269,37 @@ def validate_directory_structure(self) -> List[str]:
256269
"""ディレクトリ構造の検証"""
257270
return self._directory_validator.validate_directory_structure()
258271

259-
def get_threshold(self, level: str) -> int:
260-
"""指定レベルのthresholdを動的に取得"""
261-
return self._threshold_provider.get_threshold(level)
262-
263272
# =========================================================================
264-
# 明示的プロパティ(IDE補完対応、ThresholdProviderに委譲)
273+
# コンポーネント公開プロパティ
265274
# =========================================================================
266275

267276
@property
268-
def weekly_threshold(self) -> int:
269-
"""週次thresholdを取得"""
270-
return self._threshold_provider.weekly_threshold
271-
272-
@property
273-
def monthly_threshold(self) -> int:
274-
"""月次thresholdを取得"""
275-
return self._threshold_provider.monthly_threshold
276-
277-
@property
278-
def quarterly_threshold(self) -> int:
279-
"""四半期thresholdを取得"""
280-
return self._threshold_provider.quarterly_threshold
281-
282-
@property
283-
def annual_threshold(self) -> int:
284-
"""年次thresholdを取得"""
285-
return self._threshold_provider.annual_threshold
277+
def threshold(self) -> ThresholdProvider:
278+
"""
279+
閾値プロバイダーへのアクセス
286280
287-
@property
288-
def triennial_threshold(self) -> int:
289-
"""3年thresholdを取得"""
290-
return self._threshold_provider.triennial_threshold
281+
ARCHITECTURE: コンポーネント公開パターン
282+
Facadeがラッパーメソッドを提供する代わりに、
283+
内部コンポーネントを直接公開することで:
284+
- コードの重複を排除
285+
- IDE補完がThresholdProviderの全メソッドに対応
286+
- 責務の所在が明確
291287
292-
@property
293-
def decadal_threshold(self) -> int:
294-
"""10年thresholdを取得"""
295-
return self._threshold_provider.decadal_threshold
288+
Usage:
289+
config.threshold.get_threshold("weekly")
290+
config.threshold.weekly_threshold
291+
config.threshold.monthly_threshold
292+
"""
293+
return self._threshold_provider
296294

297-
@property
298-
def multi_decadal_threshold(self) -> int:
299-
"""数十年thresholdを取得"""
300-
return self._threshold_provider.multi_decadal_threshold
295+
def get_threshold(self, level: str) -> int:
296+
"""
297+
指定レベルのthresholdを動的に取得
301298
302-
@property
303-
def centurial_threshold(self) -> int:
304-
"""100年thresholdを取得"""
305-
return self._threshold_provider.centurial_threshold
299+
Note:
300+
後方互換性のため維持。新規コードは config.threshold.get_threshold() を推奨。
301+
"""
302+
return self._threshold_provider.get_threshold(level)
306303

307304
def show_paths(self) -> None:
308305
"""パス設定を表示(デバッグ用)"""

0 commit comments

Comments
 (0)