ponshu-room-lite/lib/services/gemini_service.dart

245 lines
8.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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?,
);
}
}