大家好!在 Day 17,我們成功地在 App 中呼叫了 Google Gemini API,並收到了 AI 對發票文字的初步分析。這是一個巨大的突破,但我們也發現了一個問題:AI 的回覆是「給人看的」自然語言摘要,而不是「給程式用的」結構化資料。
我們可能收到 「這看起來是一張 7-11 的收據,總金額大約是 90 元。」 這樣的句子。對於程式來說,要從中穩定地提取出 90
這個數字和 7-11
這個店名,是非常困難且不可靠的。
今天,我們將學習與大型語言模型(LLM)互動的核心藝術——提示工程 (Prompt Engineering)。我們將學習如何像一位專案經理一樣,給 AI 下達清晰、明確的指令,讓它不僅能完成任務,更能以我們指定的格式回報成果。
Prompt Engineering (提示工程) 簡單來說,就是設計**最佳「提示」或「指令」(Prompt) **的藝術與科學,以引導 AI 產生我們期望的、精確的輸出。
可以把 AI 想像成一個非常有才華、但需要明確指令的實習生。
一個優秀 Prompt 的關鍵要素:
Gemini API 提供了一個更強大、更可靠的功能:直接在 API 請求層級強制模型回傳指定的 MIME Type。
現在,讓我們回到 lib/services/gemini_service.dart
,對我們的 prompt
進行一次脫胎換骨的升級。
import 'dart:convert';
import 'package:google_generative_ai/google_generative_ai.dart';
class GeminiService {
// 警告:將 API 金鑰直接寫入程式碼有安全風險,後續使用環境變數管理
final String _apiKey = 'API_KEY';
Future<Map<String, dynamic>?> analyzeReceiptText(String ocrText) async {
// 如果 OCR 文字是空的或包含錯誤訊息,就直接返回,不浪費 API 呼叫
if (ocrText.isEmpty || ocrText.contains('無法辨識')) {
return {'error': 'OCR 文字為空或辨識失敗,無法進行 AI 分析。'};
}
try {
// 1. 初始化模型 (修正為有效的模型名稱)
final model = GenerativeModel(
model: 'gemini-1.5-flash-latest',
apiKey: _apiKey,
// 要求 JSON 輸出
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
),
);
// 2. 建立我們的 Prompt (提問)
final prompt = '''
你是一位專業的發票資料擷取助理。
你的任務是從發票的原始文字中提取結構化資料。
請根據以下文字,提取 "storeName", "totalAmount", "category" 三個欄位。
"category" 欄位必須是從以下列表中選擇一個最符合的選項:
["餐飲", "購物", "交通", "娛樂", "居家", "其他"]
請嚴格依照以下 JSON 格式回傳,不要包含任何 markdown 語法或額外說明:
{
"storeName": "店家名稱",
"totalAmount": 總金額 (數字),
"category": "消費分類"
}
--- 發票文字開始 ---
$ocrText
--- 發票文字結束 ---
''';
// 3. 建立傳送給 API 的內容
final content = [Content.text(prompt)];
// 4. 發送請求並等待回覆
final response = await model.generateContent(content);
final rawJson = response.text;
if (rawJson == null) {
return {'error': 'AI 未回傳任何內容。'};
}
// 5. 解析 JSON 字串 (增加對 List 格式的處理)
try {
final decodedJson = jsonDecode(rawJson);
if (decodedJson is Map<String, dynamic>) {
// 情況 1: API 回傳了預期的 JSON 物件
return decodedJson;
} else if (decodedJson is List && decodedJson.isNotEmpty) {
// 情況 2: API 回傳了 JSON 陣列,我們取第一個元素
final firstElement = decodedJson.first;
if (firstElement is Map<String, dynamic>) {
return firstElement;
}
}
// 如果格式依然不符,回報錯誤
print('Unexpected JSON format from Gemini: $rawJson');
return {'error': 'AI 回應了非預期的格式。', 'rawResponse': rawJson};
} on FormatException catch (e) {
print('JSON 解析失敗: $e');
return {'error': '無法解析 AI 回應的格式。', 'rawResponse': rawJson};
}
} on GenerativeAIException catch (e) {
// 捕捉 Gemini 特有的例外,例如 API 金鑰錯誤
print('Gemini API 錯誤: ${e.message}');
return {'error': 'AI 分析失敗,請檢查 API 金鑰或網路連線。'};
} catch (e) {
print('Gemini API 請求失敗: $e');
return {'error': 'AI 分析時發生未知錯誤,請稍後再試。'};
}
}
}
responseMimeType: 'application/json'
:這行程式碼告訴 Gemini API:「你的回覆必須是合法的 JSON」,降低收到非 JSON 格式回覆的機率。標準化的錯誤回傳
:Service 現在都會回傳一個包含 error 鍵的 Map,判斷操作是否成功,並顯示對應的錯誤訊息。有了 GeminiService 回傳的標準化 Map,我們現在可以優雅地在 ScanPage 中處理成功、失敗與載入中的各種情況。最佳實踐是將複雜的 UI 顯示邏輯抽離成一個輔助函式。
修改 GeminiService
,讓它回傳的不再是 String
,而是一個 Map<String, dynamic>
。
// lib/scan_page.dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:snapsaver/services/gemini_service.dart';
import 'package:snapsaver/services/ocr_service.dart';
class ScanPage extends StatefulWidget {
const ScanPage({super.key});
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
File? _imageFile;
final ImagePicker _picker = ImagePicker();
final OcrService _ocrService = OcrService();
final GeminiService _geminiService = GeminiService();
bool _isProcessing = false;
String? _recognizedText;
Map<String, dynamic>? _geminiResponse;
Future<void> _pickImage(ImageSource source) async {
final XFile? image = await _picker.pickImage(
source: source, maxWidth: 1600, maxHeight: 1600, imageQuality: 85);
if (image != null) {
setState(() {
_imageFile = File(image.path);
_recognizedText = null;
_geminiResponse = null;
});
}
}
Future<void> _processImage() async {
if (_imageFile == null) return;
setState(() {
_isProcessing = true;
_recognizedText = null;
_geminiResponse = null;
});
try {
final ocrText = await _ocrService.processImage(_imageFile!);
if (!mounted) return;
setState(() { _recognizedText = ocrText; });
final aiResult = await _geminiService.analyzeReceiptText(ocrText);
if (!mounted) return;
setState(() { _geminiResponse = aiResult; });
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('處理過程中發生錯誤:$e')));
} finally {
if (mounted) {
setState(() { _isProcessing = false; });
}
}
}
@override
void dispose() {
_ocrService.dispose();
super.dispose();
}
// 將複雜的 UI 顯示邏輯抽離成一個輔助函式
Widget _buildResultDisplay() {
if (_isProcessing) {
return const Center(child: CircularProgressIndicator());
}
if (_geminiResponse != null) {
if (_geminiResponse!.containsKey('error')) {
return Text(
'分析失敗:\n${_geminiResponse!['error']}',
style: TextStyle(color: Theme.of(context).colorScheme.error),
);
}
final storeName = _geminiResponse!['storeName'] ?? '未知店家';
final totalAmount = _geminiResponse!['totalAmount'] ?? '未知金額';
final category = _geminiResponse!['category'] ?? '未分類';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('AI 分析結果:', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text('店家名稱:$storeName', style: Theme.of(context).textTheme.bodyLarge),
Text('總金額:$totalAmount', style: Theme.of(context).textTheme.bodyLarge),
Text('消費分類:$category', style: Theme.of(context).textTheme.bodyLarge),
const Divider(height: 24),
Text('原始辨識文字:\n${_recognizedText ?? ''}', style: Theme.of(context).textTheme.bodySmall),
],
);
}
if (_recognizedText != null) {
return Text('OCR 辨識結果:\n$_recognizedText');
}
return const Text('點擊右下角「開始辨識」按鈕進行分析...');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('掃描發票')),
body: Column(
children: [
Expanded(
child: _imageFile == null
? const Center(child: Text('尚未選擇圖片'))
: Container(
padding: const EdgeInsets.all(16.0),
child: Image.file(_imageFile!),
),
),
Expanded(
flex: 2,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: _buildResultDisplay(),
),
),
],
),
bottomNavigationBar: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton(icon: const Icon(Icons.camera_alt), onPressed: () => _pickImage(ImageSource.camera)),
IconButton(icon: const Icon(Icons.photo_library), onPressed: () => _pickImage(ImageSource.gallery)),
IconButton(
icon: const Icon(Icons.analytics),
onPressed: (_imageFile != null && !_isProcessing) ? _processImage : null,
tooltip: '開始辨識與分析',
),
],
),
),
);
}
}
_buildResultDisplay()
:我們將所有關於「如何顯示結果」的 if-else 判斷,全部封裝在這個函式中。這讓 build 方法本身變得極其乾淨,只負責佈局,不負責邏輯。_buildResultDisplay
的邏輯非常清晰:優先處理載入中 -> 處理 AI 回應(包括成功和錯誤兩種情況)-> 處理只有 OCR 結果的情況 -> 顯示初始提示。這為使用者提供了極佳的體驗。當 AI 分析完成後,畫面上顯示的不再是一大段文字,而是被清晰地拆解開的「店家名稱」、「總金額」和「建議分類」!
今天我們完成了從「與 AI 聊天」到「向 AI 下達指令」的關鍵轉變。我們成功地:
我們已經讓 AI 從一個創意無限的藝術家,變成一位聽從指令、高效可靠的資料助理。
明天,在 「串連一切」 的章節中,我們將打通最後的任督二脈:在 ScanPage
新增一個按鈕,點擊後,將這些 AI 解析出的資料,自動地預先填入「新增消費」的表單中,實現真正的「一鍵掃描,輕鬆記帳」!