407 lines
12 KiB
Markdown
407 lines
12 KiB
Markdown
# 「あわせて飲みたい」機能拡張計画
|
||
|
||
**作成日**: 2026-01-22
|
||
**ステータス**: Phase 2.0(リリース後1ヶ月)で実装予定
|
||
|
||
---
|
||
|
||
## 📊 現状分析
|
||
|
||
### 実装済み機能
|
||
✅ **ローカルレコメンドエンジン**:
|
||
- 五味チャートのコサイン類似度計算
|
||
- 酒蔵・都道府県・タグによる類似度スコアリング
|
||
- スコア順にソート(最大10件)
|
||
|
||
### 制限事項
|
||
❌ **登録済みの銘柄のみ**:
|
||
- ユーザーが登録した日本酒からのみレコメンド
|
||
- 未知の銘柄(まだ登録していない日本酒)は推薦されない
|
||
|
||
---
|
||
|
||
## 🎯 Phase 2.0: 未知の銘柄のレコメンド(拡張計画)
|
||
|
||
### 1. Synology NAS上の共有データベース構築
|
||
|
||
**アーキテクチャ**:
|
||
```
|
||
┌─────────────────────────────────────┐
|
||
│ Synology NAS (PostgreSQL) │
|
||
│ posimai-nas.ts.net (Tailscale) │
|
||
│ │
|
||
│ ┌──────────────────────────────┐ │
|
||
│ │ 日本酒マスターDB │ │
|
||
│ │ │ │
|
||
│ │ - 銘柄名 │ │
|
||
│ │ - 蔵元 │ │
|
||
│ │ - 都道府県 │ │
|
||
│ │ - 五味チャート(平均値) │ │
|
||
│ │ - タグ │ │
|
||
│ │ - 人気度(登録回数) │ │
|
||
│ └──────────────────────────────┘ │
|
||
│ ↓ │
|
||
│ ┌──────────────────────────────┐ │
|
||
│ │ ユーザー登録DB │ │
|
||
│ │ │ │
|
||
│ │ - user_id (匿名化) │ │
|
||
│ │ - sake_id │ │
|
||
│ │ - 評価・レビュー │ │
|
||
│ │ - 五味チャート │ │
|
||
│ └──────────────────────────────┘ │
|
||
└─────────────────────────────────────┘
|
||
↑ HTTPS (Tailscale)
|
||
┌─────────────────────────────────────┐
|
||
│ Flutter App │
|
||
│ │
|
||
│ - 自分のカード(Hive) │
|
||
│ - 未知の銘柄(API経由) │
|
||
└─────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
### 2. API設計
|
||
|
||
#### エンドポイント: `/api/v1/recommendations`
|
||
|
||
**リクエスト**:
|
||
```json
|
||
{
|
||
"target": {
|
||
"prefecture": "新潟県",
|
||
"type": "純米吟醸",
|
||
"taste_stats": {
|
||
"aroma": 4,
|
||
"sweetness": 3,
|
||
"acidity": 3,
|
||
"bitterness": 2,
|
||
"body": 4
|
||
},
|
||
"flavor_tags": ["フルーティー", "すっきり"]
|
||
},
|
||
"exclude_ids": ["abc123", "def456"], // 既に登録済みの銘柄
|
||
"limit": 5
|
||
}
|
||
```
|
||
|
||
**レスポンス**:
|
||
```json
|
||
{
|
||
"recommendations": [
|
||
{
|
||
"id": "sake_12345",
|
||
"name": "八海山 純米吟醸",
|
||
"brewery": "八海醸造",
|
||
"prefecture": "新潟県",
|
||
"type": "純米吟醸",
|
||
"taste_stats": {
|
||
"aroma": 4,
|
||
"sweetness": 3,
|
||
"acidity": 3,
|
||
"bitterness": 2,
|
||
"body": 4
|
||
},
|
||
"flavor_tags": ["フルーティー", "すっきり"],
|
||
"similarity_score": 0.92,
|
||
"reason": "新潟県つながり / すっきり / 似た味わい",
|
||
"popularity": 1523 // 何人が登録したか
|
||
},
|
||
// ... 最大5件
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3. ハイブリッドレコメンド実装
|
||
|
||
```dart
|
||
// lib/services/sake_recommendation_service.dart
|
||
class SakeRecommendationService {
|
||
/// ハイブリッドレコメンド(既存 + 未知の銘柄)
|
||
static Future<List<RecommendedSake>> getHybridRecommendations({
|
||
required SakeItem target,
|
||
required List<SakeItem> ownItems,
|
||
int limit = 10,
|
||
}) async {
|
||
final recommendations = <RecommendedSake>[];
|
||
|
||
// 1. 既存の銘柄から類似を検索(ローカル)
|
||
final ownRecs = getRecommendations(
|
||
target: target,
|
||
allItems: ownItems,
|
||
limit: 5, // 半分
|
||
);
|
||
recommendations.addAll(ownRecs);
|
||
|
||
// 2. 未知の銘柄を検索(API経由)
|
||
try {
|
||
final unknownRecs = await _fetchUnknownRecommendations(
|
||
target: target,
|
||
excludeIds: ownItems.map((item) => item.id).toList(),
|
||
limit: 5, // 残り半分
|
||
);
|
||
recommendations.addAll(unknownRecs);
|
||
} catch (e) {
|
||
debugPrint('⚠️ Failed to fetch unknown recommendations: $e');
|
||
// エラー時は既存のみ表示
|
||
}
|
||
|
||
// 3. スコア順にソート
|
||
recommendations.sort((a, b) => b.score.compareTo(a.score));
|
||
|
||
return recommendations.take(limit).toList();
|
||
}
|
||
|
||
/// 未知の銘柄をAPIから取得
|
||
static Future<List<RecommendedSake>> _fetchUnknownRecommendations({
|
||
required SakeItem target,
|
||
required List<String> excludeIds,
|
||
int limit = 5,
|
||
}) async {
|
||
final response = await http.post(
|
||
Uri.parse('https://posimai-nas.ts.net/api/v1/recommendations'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode({
|
||
'target': {
|
||
'prefecture': target.displayData.prefecture,
|
||
'type': target.displayData.type,
|
||
'taste_stats': target.hiddenSpecs.tasteStats,
|
||
'flavor_tags': target.hiddenSpecs.flavorTags,
|
||
},
|
||
'exclude_ids': excludeIds,
|
||
'limit': limit,
|
||
}),
|
||
);
|
||
|
||
if (response.statusCode != 200) {
|
||
throw Exception('API Error: ${response.statusCode}');
|
||
}
|
||
|
||
final data = jsonDecode(response.body);
|
||
return (data['recommendations'] as List)
|
||
.map((json) => RecommendedSake.fromJson(json))
|
||
.toList();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 4. UI改善
|
||
|
||
**Before** (現在):
|
||
```dart
|
||
Text('五味チャート・タグ・酒蔵・産地から自動選出\n※現在は登録済みの銘柄からおすすめを表示')
|
||
```
|
||
|
||
**After** (Phase 2.0):
|
||
```dart
|
||
Text('五味チャート・タグ・酒蔵・産地から自動選出\n💡 あなたにおすすめの未知の銘柄も表示中')
|
||
```
|
||
|
||
**未知の銘柄のカードにバッジ表示**:
|
||
```dart
|
||
// 未知の銘柄には「🆕 未登録」バッジを表示
|
||
Stack(
|
||
children: [
|
||
Image.network(unknownSake.imageUrl),
|
||
if (unknownSake.isUnknown)
|
||
Positioned(
|
||
top: 8,
|
||
right: 8,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.orange,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text('🆕 未登録', style: TextStyle(color: Colors.white, fontSize: 10)),
|
||
),
|
||
),
|
||
],
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
### 5. データ収集戦略
|
||
|
||
#### ステップ1: 既存ユーザーのデータを匿名化して収集
|
||
|
||
**実装**:
|
||
- アプリ初回起動時に「データ提供の同意」を取得
|
||
- ユーザーが登録した日本酒のデータをNASに送信
|
||
- `user_id`は匿名化(`device_info_plus`でデバイスIDをハッシュ化)
|
||
|
||
```dart
|
||
// lib/services/data_contribution_service.dart
|
||
class DataContributionService {
|
||
static Future<void> uploadSakeData() async {
|
||
final userProfile = ref.read(userProfileProvider);
|
||
|
||
// ユーザーが同意していない場合は送信しない
|
||
if (!userProfile.hasConsentedToDataSharing) return;
|
||
|
||
final box = Hive.box<SakeItem>('sake_items');
|
||
final allItems = box.values.toList();
|
||
|
||
final payload = allItems.map((item) => {
|
||
'name': item.displayData.name,
|
||
'brewery': item.displayData.brewery,
|
||
'prefecture': item.displayData.prefecture,
|
||
'type': item.displayData.type,
|
||
'taste_stats': item.hiddenSpecs.tasteStats,
|
||
'flavor_tags': item.hiddenSpecs.flavorTags,
|
||
'smv': item.hiddenSpecs.sakeMeterValue,
|
||
}).toList();
|
||
|
||
await http.post(
|
||
Uri.parse('https://posimai-nas.ts.net/api/v1/data/upload'),
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: jsonEncode({
|
||
'user_id': await _getAnonymizedUserId(),
|
||
'sake_items': payload,
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### ステップ2: 外部データソースから収集
|
||
|
||
1. **日本酒造組合中央会のデータ**:
|
||
- 公開されている酒蔵リスト
|
||
- 都道府県別の銘柄情報
|
||
|
||
2. **酒蔵の公式Webサイト**:
|
||
- 各銘柄の説明文
|
||
- 五味チャートの情報(公開されている場合)
|
||
|
||
3. **Gemini APIによる自動収集**:
|
||
- 日本酒の名前と蔵元から五味チャートを推定
|
||
- コスト: 約5円/銘柄(画像なし、テキストのみ)
|
||
|
||
---
|
||
|
||
### 6. データベーススキーマ
|
||
|
||
**テーブル: `sake_master`**
|
||
```sql
|
||
CREATE TABLE sake_master (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
name VARCHAR(255) NOT NULL,
|
||
brewery VARCHAR(255) NOT NULL,
|
||
prefecture VARCHAR(50) NOT NULL,
|
||
type VARCHAR(50), -- 純米吟醸, 大吟醸, etc.
|
||
taste_aroma INT DEFAULT 3,
|
||
taste_sweetness INT DEFAULT 3,
|
||
taste_acidity INT DEFAULT 3,
|
||
taste_bitterness INT DEFAULT 3,
|
||
taste_body INT DEFAULT 3,
|
||
flavor_tags TEXT[], -- {フルーティー, すっきり}
|
||
smv DECIMAL(3,1), -- 日本酒度
|
||
popularity INT DEFAULT 0, -- 何人が登録したか
|
||
image_url TEXT,
|
||
created_at TIMESTAMP DEFAULT NOW(),
|
||
updated_at TIMESTAMP DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_sake_prefecture ON sake_master(prefecture);
|
||
CREATE INDEX idx_sake_type ON sake_master(type);
|
||
CREATE INDEX idx_sake_popularity ON sake_master(popularity DESC);
|
||
```
|
||
|
||
**テーブル: `user_sake_data`**
|
||
```sql
|
||
CREATE TABLE user_sake_data (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id_hash VARCHAR(64) NOT NULL, -- 匿名化されたユーザーID
|
||
sake_id UUID REFERENCES sake_master(id),
|
||
taste_aroma INT,
|
||
taste_sweetness INT,
|
||
taste_acidity INT,
|
||
taste_bitterness INT,
|
||
taste_body INT,
|
||
rating INT, -- 1-5
|
||
created_at TIMESTAMP DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX idx_user_sake_user ON user_sake_data(user_id_hash);
|
||
CREATE INDEX idx_user_sake_sake ON user_sake_data(sake_id);
|
||
```
|
||
|
||
---
|
||
|
||
### 7. 実装工数見積もり
|
||
|
||
| 項目 | 工数 | 担当 |
|
||
|------|------|------|
|
||
| **PostgreSQLセットアップ** | 4時間 | Synology NAS |
|
||
| **APIサーバー構築(FastAPI)** | 8時間 | Python |
|
||
| **レコメンドアルゴリズム(サーバー側)** | 6時間 | Python |
|
||
| **Flutter側の統合** | 6時間 | Dart |
|
||
| **データ収集機能** | 4時間 | Dart + Python |
|
||
| **テスト・調整** | 4時間 | 総合 |
|
||
| **合計** | **32時間** | 約4日 |
|
||
|
||
---
|
||
|
||
### 8. リリーススケジュール
|
||
|
||
#### Phase 1.0(現在)
|
||
- ✅ ローカルレコメンドエンジン実装済み
|
||
- ✅ 既存の銘柄からの推薦
|
||
|
||
#### Phase 2.0(リリース後1ヶ月)
|
||
- 🔄 Synology NAS環境構築
|
||
- 🔄 日本酒マスターDB構築(初期データ: 100銘柄)
|
||
- 🔄 ハイブリッドレコメンド実装
|
||
- 🔄 データ収集機能実装
|
||
|
||
#### Phase 3.0(リリース後3ヶ月)
|
||
- 🔮 ソーシャル機能統合
|
||
- 🔮 「この銘柄を登録している人はこれも登録しています」
|
||
- 🔮 コミュニティの人気ランキング
|
||
|
||
---
|
||
|
||
### 9. ユーザーへの説明
|
||
|
||
**現在の表示**:
|
||
```
|
||
「あわせて飲みたい」
|
||
五味チャート・タグ・酒蔵・産地から自動選出
|
||
※現在は登録済みの銘柄からおすすめを表示
|
||
```
|
||
|
||
**Phase 2.0での表示**:
|
||
```
|
||
「あわせて飲みたい」
|
||
五味チャート・タグ・酒蔵・産地から自動選出
|
||
💡 あなたにおすすめの未知の銘柄も表示中
|
||
```
|
||
|
||
**バッジ**:
|
||
- 🆕 未登録: まだ登録していない銘柄
|
||
- 📚 登録済み: 自分のカードから推薦
|
||
|
||
---
|
||
|
||
## 📝 まとめ
|
||
|
||
**現状**: 既に優秀なローカルレコメンドエンジンが実装済み
|
||
|
||
**Phase 2.0での拡張**: 未知の銘柄を含めたハイブリッドレコメンドを実装予定
|
||
|
||
**実装工数**: 約32時間(4日)
|
||
|
||
**リリース時期**: リリース後1ヶ月(2026年3月頃)
|
||
|
||
---
|
||
|
||
**作成日**: 2026-01-22
|
||
**作成者**: Cursor AI
|
||
**レビュー**: 開発者(必要に応じて修正してください)
|