iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
自我挑戰組

攜手 AI 從零開始打造一款 Flutter 應用程式系列 第 18

Day 18: 讓 AI 聽懂人話 - Prompt Engineering 與 JSON 解析

  • 分享至 

  • xImage
  •  

前言

大家好!在 Day 17,我們成功地在 App 中呼叫了 Google Gemini API,並收到了 AI 對發票文字的初步分析。這是一個巨大的突破,但我們也發現了一個問題:AI 的回覆是「給人看的」自然語言摘要,而不是「給程式用的」結構化資料。

我們可能收到 「這看起來是一張 7-11 的收據,總金額大約是 90 元。」 這樣的句子。對於程式來說,要從中穩定地提取出 90 這個數字和 7-11 這個店名,是非常困難且不可靠的。

今天,我們將學習與大型語言模型(LLM)互動的核心藝術——提示工程 (Prompt Engineering)。我們將學習如何像一位專案經理一樣,給 AI 下達清晰、明確的指令,讓它不僅能完成任務,更能以我們指定的格式回報成果。

Step 1: 什麼是 Prompt Engineering?

Prompt Engineering (提示工程) 簡單來說,就是設計**最佳「提示」「指令」(Prompt) **的藝術與科學,以引導 AI 產生我們期望的、精確的輸出。

可以把 AI 想像成一個非常有才華、但需要明確指令的實習生。

  • 糟糕的指令 (昨天的我們):「看一下這份文件,跟我說說大概內容。」 -> 你會得到一份心得總結。
  • 優秀的指令 (今天的目標):「你是一位資料分析專家。請閱讀這份文件,並幫我提取『店家名稱』(storeName)、『總金額』(totalAmount) 和『消費分類』(category) 這三個欄位的資訊。請只回傳一個包含這些欄位的 JSON 物件,不要有任何額外的文字。」 -> 你會得到一份乾淨、可用的結構化數據。

一個優秀 Prompt 的關鍵要素:

  1. 角色扮演 (Role-Playing):賦予 AI 一個身份,例如「你是一位專業的發票資料擷取助理」。
  2. 明確指令 (Clear Instructions):清楚告知 AI 要做什麼,例如「請從文字中提取...」。
  3. 輸出格式化 (Output Formatting):強制要求 AI 以特定格式回傳,例如「請只用一個合法的 JSON 物件回覆」。
  4. 提供範例 (Few-shot Learning):給 AI 一兩個輸入和輸出的範例,能讓它更精準地理解你的要求(我們今天暫時不用,但這是進階技巧)。

Step 2: 升級 GeminiService - 命令 AI 回傳 JSON

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,判斷操作是否成功,並顯示對應的錯誤訊息。

Step 3: 升級 ScanPage - 專業地呈現結果

有了 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 分析完成後,畫面上顯示的不再是一大段文字,而是被清晰地拆解開的「店家名稱」、「總金額」和「建議分類」!

https://ithelp.ithome.com.tw/upload/images/20251002/20163912Saq0KhgOqy.png

今日結語

今天我們完成了從「與 AI 聊天」到「向 AI 下達指令」的關鍵轉變。我們成功地:

  1. 學習了 Prompt Engineering 的核心思想:角色扮演、明確指令、格式化輸出。
  2. 設計了一個能引導 Gemini 回傳結構化 JSON 的強力 Prompt。
  3. 建立了一套標準化的錯誤處理機制。
  4. 透過輔助函式讓 UI 程式碼更清晰、更易於維護。

我們已經讓 AI 從一個創意無限的藝術家,變成一位聽從指令、高效可靠的資料助理。

明天,在 「串連一切」 的章節中,我們將打通最後的任督二脈:在 ScanPage 新增一個按鈕,點擊後,將這些 AI 解析出的資料,自動地預先填入「新增消費」的表單中,實現真正的「一鍵掃描,輕鬆記帳」!


上一篇
Day 17: App 的 AI 大腦上線 - 串接 Google Gemini API
下一篇
Day 19: 串連一切 - 實現一鍵掃描,智慧填單
系列文
攜手 AI 從零開始打造一款 Flutter 應用程式20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言