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
|
|||
|
|
**レビュー**: 開発者(必要に応じて修正してください)
|