245 lines
8.2 KiB
Dart
245 lines
8.2 KiB
Dart
import 'dart:io';
|
||
import 'dart:convert';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:flutter/foundation.dart';
|
||
import 'device_service.dart';
|
||
// import '../secrets.dart'; // No longer needed
|
||
|
||
|
||
class GeminiService {
|
||
// AI Proxy Server Configuration
|
||
static const String _proxyUrl = 'http://192.168.31.89:8080/analyze';
|
||
|
||
// レート制限対策? Proxy側で管理されているが、念のためクライアント側でも連打防止
|
||
static DateTime? _lastApiCallTime;
|
||
static const Duration _minApiInterval = Duration(seconds: 2);
|
||
|
||
GeminiService();
|
||
|
||
/// 画像リストから日本酒ラベルを解析
|
||
Future<SakeAnalysisResult> analyzeSakeLabel(List<String> imagePaths) async {
|
||
// サーバー側のデフォルトプロンプトを使用するため、customPromptはnullを送信
|
||
return _callProxyApi(
|
||
imagePaths: imagePaths,
|
||
customPrompt: null,
|
||
);
|
||
}
|
||
|
||
/// OCRテキストと画像のハイブリッド解析
|
||
Future<SakeAnalysisResult> analyzeSakeHybrid(String extractedText, List<String> imagePaths) async {
|
||
final prompt = '''
|
||
以下のOCR抽出テキストと添付の日本酒ラベル画像を組み合わせて分析してください。
|
||
OCRの性質上、テキストには誤字や脱落が含まれますが、添付の画像で実際の表記を確認し、正しい情報を推測・補完してください。
|
||
|
||
テキストで断片的な情報があれば、画像で全体のバランスを見て正式な商品名を特定してください。
|
||
|
||
抽出テキスト:
|
||
"""
|
||
$extractedText
|
||
"""
|
||
|
||
以下の情報をJSON形式で返してください:
|
||
{
|
||
"name": "銘柄名",
|
||
"brand": "蔵元名",
|
||
"prefecture": "都道府県名",
|
||
"type": "特定名称",
|
||
"description": "特徴(100文字)",
|
||
"catchCopy": "キャッチコピー(20文字)",
|
||
"confidenceScore": 0-100,
|
||
"flavorTags": ["タグ"],
|
||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||
"alcoholContent": 15.5,
|
||
"polishingRatio": 50,
|
||
"sakeMeterValue": 3.0,
|
||
"riceVariety": "山田錦",
|
||
"yeast": "きょうかい9号",
|
||
"manufacturingYearMonth": "2023.10"
|
||
}
|
||
''';
|
||
|
||
return _callProxyApi(imagePaths: imagePaths, customPrompt: prompt);
|
||
}
|
||
|
||
/// テキストのみの解析 (画像なし)
|
||
Future<SakeAnalysisResult> analyzeSakeText(String extractedText) async {
|
||
final prompt = '''
|
||
以下のOCRで抽出された日本酒ラベルのテキスト情報を分析してください。
|
||
誤字やノイズが含まれることが多いですが、文脈から積極的に正しい情報を推測・補完してください。
|
||
|
||
抽出テキスト:
|
||
"""
|
||
$extractedText
|
||
"""
|
||
|
||
以下の情報をJSON形式で返してください:
|
||
{
|
||
"name": "銘柄名",
|
||
"brand": "蔵元名",
|
||
"prefecture": "都道府県名",
|
||
"type": "特定名称",
|
||
"description": "特徴(100文字)",
|
||
"catchCopy": "キャッチコピー(20文字)",
|
||
"confidenceScore": 0-100,
|
||
"flavorTags": ["タグ"],
|
||
"tasteStats": {"aroma":3,"sweetness":3,"acidity":3,"bitterness":3,"body":3},
|
||
"alcoholContent": 15.5,
|
||
"polishingRatio": 50,
|
||
"sakeMeterValue": 3.0,
|
||
"riceVariety": "山田錦",
|
||
"yeast": "きょうかい9号",
|
||
"manufacturingYearMonth": "2023.10"
|
||
}
|
||
''';
|
||
|
||
return _callProxyApi(imagePaths: [], customPrompt: prompt);
|
||
}
|
||
|
||
/// 共通実装: ProxyへのAPIコール
|
||
Future<SakeAnalysisResult> _callProxyApi({
|
||
required List<String> imagePaths,
|
||
String? customPrompt,
|
||
}) async {
|
||
try {
|
||
// 1. レート制限 (クライアント側連打防止)
|
||
if (_lastApiCallTime != null) {
|
||
final elapsed = DateTime.now().difference(_lastApiCallTime!);
|
||
if (elapsed < _minApiInterval) {
|
||
await Future.delayed(_minApiInterval - elapsed);
|
||
}
|
||
}
|
||
_lastApiCallTime = DateTime.now();
|
||
|
||
// 2. 画像をBase64変換
|
||
List<String> base64Images = [];
|
||
for (final path in imagePaths) {
|
||
final bytes = await File(path).readAsBytes();
|
||
final base64String = base64Encode(bytes);
|
||
base64Images.add(base64String);
|
||
debugPrint('Encoded image: ${(bytes.length / 1024).toStringAsFixed(1)}KB');
|
||
}
|
||
|
||
// 3. デバイスID取得
|
||
final deviceId = await DeviceService.getDeviceId();
|
||
debugPrint('Device ID: $deviceId');
|
||
|
||
// 4. リクエスト作成
|
||
final requestBody = jsonEncode({
|
||
"device_id": deviceId,
|
||
"images": base64Images,
|
||
"prompt": customPrompt,
|
||
});
|
||
|
||
debugPrint('Calling Proxy: $_proxyUrl');
|
||
|
||
// 5. 送信
|
||
final response = await http.post(
|
||
Uri.parse(_proxyUrl),
|
||
headers: {"Content-Type": "application/json"},
|
||
body: requestBody,
|
||
).timeout(const Duration(seconds: 45)); // 画像アップロード含むため長めに
|
||
|
||
// 6. レスポンス処理
|
||
if (response.statusCode == 200) {
|
||
// 成功時のレスポンス形式: { "success": true, "data": {...}, "usage": {...} }
|
||
final jsonResponse = jsonDecode(utf8.decode(response.bodyBytes));
|
||
|
||
if (jsonResponse['success'] == true) {
|
||
final data = jsonResponse['data'];
|
||
if (data == null) throw Exception("サーバーからのデータが空です");
|
||
|
||
// 使用状況ログ
|
||
if (jsonResponse['usage'] != null) {
|
||
final usage = jsonResponse['usage'];
|
||
debugPrint('API Usage: ${usage['today']}/${usage['limit']}');
|
||
}
|
||
|
||
return SakeAnalysisResult.fromJson(data);
|
||
} else {
|
||
// Proxy側での論理エラー (レート制限超過など)
|
||
throw Exception(jsonResponse['error'] ?? '不明なエラーが発生しました');
|
||
}
|
||
} else {
|
||
// HTTPエラー
|
||
debugPrint('Proxy Error: ${response.statusCode} ${response.body}');
|
||
throw Exception('サーバーエラー (${response.statusCode}): ${response.body}');
|
||
}
|
||
|
||
} catch (e) {
|
||
debugPrint('Proxy Call Failed: $e');
|
||
// エラーメッセージを整形
|
||
final errorMsg = e.toString().toLowerCase();
|
||
if (errorMsg.contains('limit') || errorMsg.contains('上限')) {
|
||
throw Exception('本日のAI解析リクエスト上限に達しました。\n明日またお試しください。');
|
||
}
|
||
rethrow;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Analysis Result Model
|
||
class SakeAnalysisResult {
|
||
final String? name;
|
||
final String? brand;
|
||
final String? prefecture;
|
||
final String? type;
|
||
final String? description;
|
||
final String? catchCopy;
|
||
final int? confidenceScore;
|
||
final List<String> flavorTags;
|
||
final Map<String, int> tasteStats;
|
||
|
||
// New Fields
|
||
final double? alcoholContent;
|
||
final int? polishingRatio;
|
||
final double? sakeMeterValue;
|
||
final String? riceVariety;
|
||
final String? yeast;
|
||
final String? manufacturingYearMonth;
|
||
|
||
SakeAnalysisResult({
|
||
this.name,
|
||
this.brand,
|
||
this.prefecture,
|
||
this.type,
|
||
this.description,
|
||
this.catchCopy,
|
||
this.confidenceScore,
|
||
this.flavorTags = const [],
|
||
this.tasteStats = const {},
|
||
this.alcoholContent,
|
||
this.polishingRatio,
|
||
this.sakeMeterValue,
|
||
this.riceVariety,
|
||
this.yeast,
|
||
this.manufacturingYearMonth,
|
||
});
|
||
|
||
factory SakeAnalysisResult.fromJson(Map<String, dynamic> json) {
|
||
// Helper to extract int from map safely
|
||
Map<String, int> stats = {};
|
||
if (json['tasteStats'] is Map) {
|
||
final map = json['tasteStats'] as Map;
|
||
stats = map.map((key, value) => MapEntry(key.toString(), (value as num?)?.toInt() ?? 3));
|
||
}
|
||
|
||
return SakeAnalysisResult(
|
||
name: json['name'] as String?,
|
||
brand: json['brand'] as String?,
|
||
prefecture: json['prefecture'] as String?,
|
||
type: json['type'] as String?,
|
||
description: json['description'] as String?,
|
||
catchCopy: json['catchCopy'] as String?,
|
||
confidenceScore: json['confidenceScore'] as int?,
|
||
flavorTags: (json['flavorTags'] as List<dynamic>?)?.map((e) => e.toString()).toList() ?? [],
|
||
tasteStats: stats,
|
||
alcoholContent: (json['alcoholContent'] as num?)?.toDouble(),
|
||
polishingRatio: (json['polishingRatio'] as num?)?.toInt(),
|
||
sakeMeterValue: (json['sakeMeterValue'] as num?)?.toDouble(),
|
||
riceVariety: json['riceVariety'] as String?,
|
||
yeast: json['yeast'] as String?,
|
||
manufacturingYearMonth: json['manufacturingYearMonth'] as String?,
|
||
);
|
||
}
|
||
}
|