qwen_ocr_client.dart 9.22 KB
import 'dart:convert';
import 'dart:typed_data';

import 'package:dio/dio.dart';

import 'package:one_key/pages/agent/reservation_image_parser.dart';

/// 阿里云百炼(DashScope)千问视觉模型客户端。
///
/// 使用 OpenAI 兼容模式,让 qwen-vl-plus 直接从图片中提取预订字段,
/// 并返回结构化 JSON,匹配 [ReservationImageParseResult] 的字段。
class QwenOcrClient {
  QwenOcrClient({
    required this.apiKey,
    this.model = 'qwen-vl-plus',
  });

  final String apiKey;
  final String model;

  static const String _endpoint =
      'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';

  final Dio _dio = Dio(
    BaseOptions(
      connectTimeout: const Duration(seconds: 20),
      receiveTimeout: const Duration(seconds: 60),
      responseType: ResponseType.json,
    ),
  );

  String _systemPrompt() {
    final DateTime now = DateTime.now();
    final String today = '${now.year}-${now.month.toString().padLeft(2, '0')}-'
        '${now.day.toString().padLeft(2, '0')}';
    return '今天是 $today。'
        '你是餐厅预订信息提取助手。请仔细识别图片中的文字,并从微信聊天记录、'
        '预订截图或手写信息中提取餐厅预订字段。'
        '只输出一个 JSON 对象,不要输出任何解释或 Markdown 代码块标记。\n\n'
        'JSON 字段说明:\n'
        '- name: 预订人姓名,去掉“先生/女士”等称谓\n'
        '- sex: 称谓,只能是 先生、女士、公司、贵宾 之一,没有则为空字符串\n'
        '- phone: 手机号,没有则为空字符串\n'
        '- party: 用餐人数,纯数字字符串,没有则为空字符串\n'
        '- date: 预订日期,格式 YYYY-MM-DD;必须是今天或未来日期;只有月日或星期时,按今天 $today 推断为下一个未来日期并补全年份\n'
        '- meal: 用餐时段,只能是 早餐、午餐、晚餐 之一;根据时间和上下文推断,没有则为空字符串\n'
        '- time: 到店时间,格式 HH:mm,没有则为空字符串\n'
        '- table: 桌位号或包间号,没有则为空字符串\n'
        '- remark: 备注/特殊要求,没有则为空字符串\n'
        '- rawText: 图片中识别出的完整文字,按原顺序用换行拼接\n\n'
        '输出示例:\n'
        '{"name":"王先生","sex":"先生","phone":"13800138000",'
        '"party":"8","date":"$today","meal":"晚餐",'
        '"time":"18:30","table":"A8","remark":"靠窗",'
        '"rawText":"王先生 13800138000\\n9月25日晚餐 8人"}';
  }

  /// 从图片中提取预订字段;结构化识别失败时自动降级为正则解析。
  Future<ReservationImageParseResult> parseReservation(
    Uint8List imageBytes,
  ) async {
    if (imageBytes.isEmpty) {
      throw const QwenOcrException(0, '图片内容为空');
    }

    final String content = await _chat(imageBytes);
    final Map<String, dynamic>? json = _parseJsonObject(content);

    final ReservationImageParseResult parsed =
        ReservationImageParser.parse(content);

    if (json == null) {
      return parsed;
    }

    String field(String key) {
      final dynamic value = json[key];
      if (value == null) return '';
      return value.toString().trim();
    }

    final String rawText =
        field('rawText').isNotEmpty ? field('rawText') : content;
    final ReservationImageParseResult regexParsed =
        ReservationImageParser.parse(rawText);

    String firstNonEmpty(String primary, String fallback) {
      return primary.isNotEmpty ? primary : fallback;
    }

    return ReservationImageParseResult(
      name: firstNonEmpty(field('name'), regexParsed.name),
      sex: _normalizeSex(firstNonEmpty(field('sex'), regexParsed.sex)),
      phone: firstNonEmpty(field('phone'), regexParsed.phone),
      party: firstNonEmpty(field('party'), regexParsed.party),
      date: firstNonEmpty(field('date'), regexParsed.date),
      meal: _normalizeMeal(firstNonEmpty(field('meal'), regexParsed.meal)),
      time: firstNonEmpty(field('time'), regexParsed.time),
      table: firstNonEmpty(field('table'), regexParsed.table),
      remark: firstNonEmpty(field('remark'), regexParsed.remark),
      rawText: rawText,
    );
  }

  Future<String> _chat(Uint8List imageBytes) async {
    final String base64 = base64Encode(imageBytes);
    final String mime = _inferMimeType(imageBytes);

    final Response<Map<String, dynamic>> response;
    try {
      response = await _dio.post<Map<String, dynamic>>(
        _endpoint,
        data: <String, dynamic>{
          'model': model,
          'temperature': 0.1,
          'messages': <Map<String, dynamic>>[
            <String, dynamic>{
              'role': 'system',
              'content': _systemPrompt(),
            },
            <String, dynamic>{
              'role': 'user',
              'content': <Map<String, dynamic>>[
                <String, dynamic>{
                  'type': 'image_url',
                  'image_url': <String, String>{
                    'url': 'data:$mime;base64,$base64',
                  },
                },
                const <String, String>{
                  'type': 'text',
                  'text': '请提取图片中的餐厅预订信息并返回 JSON。',
                },
              ],
            },
          ],
        },
        options: Options(
          headers: <String, String>{
            'Authorization': 'Bearer $apiKey',
            'Content-Type': 'application/json',
          },
        ),
      );
    } on DioException catch (error) {
      throw QwenOcrException(
        error.response?.statusCode ?? 0,
        _friendlyDioError(error),
      );
    }

    final Map<String, dynamic>? data = response.data;
    if (data == null) {
      throw const QwenOcrException(0, '图片识别返回数据异常');
    }

    final dynamic error = data['error'];
    if (error is Map) {
      throw QwenOcrException(
        error['code']?.toString() ?? '0',
        error['message']?.toString() ?? '图片识别失败',
      );
    }

    final List<dynamic>? choices = data['choices'] as List<dynamic>?;
    final dynamic message =
        choices != null && choices.isNotEmpty
        ? (choices.first as Map<String, dynamic>)['message']
        : null;
    if (message is! Map) {
      throw const QwenOcrException(0, '图片识别未返回有效内容');
    }

    final dynamic content = message['content'];
    if (content is String) {
      return content.trim();
    }
    if (content is List) {
      final StringBuffer buffer = StringBuffer();
      for (final dynamic item in content) {
        if (item is Map) {
          final dynamic text = item['text'];
          if (text != null) {
            buffer.write(text.toString());
          }
        }
      }
      return buffer.toString().trim();
    }
    return '';
  }

  Map<String, dynamic>? _parseJsonObject(String content) {
    final String trimmed = content.trim();
    if (trimmed.isEmpty) return null;

    final int start = trimmed.indexOf('{');
    final int end = trimmed.lastIndexOf('}');
    if (start < 0 || end <= start) return null;

    try {
      final dynamic decoded = jsonDecode(trimmed.substring(start, end + 1));
      if (decoded is Map<String, dynamic>) {
        return decoded;
      }
    } catch (_) {
      return null;
    }
    return null;
  }

  String _normalizeSex(String value) {
    if (value == '男') return '先生';
    if (value == '女' || value == '小姐') return '女士';
    return value;
  }

  String _normalizeMeal(String value) {
    if (value.contains('早') || value.contains('上午') || value.contains('早上')) {
      return '早餐';
    }
    if (value.contains('晚') ||
        value.contains('夜') ||
        value.contains('宵') ||
        value.contains('下午')) {
      return '晚餐';
    }
    if (value.contains('午') || value.contains('中')) {
      return '午餐';
    }
    return value;
  }

  String _friendlyDioError(DioException error) {
    final int? statusCode = error.response?.statusCode;
    if (statusCode == 401) return '千问 API Key 无效,请检查百炼密钥';
    if (statusCode == 429) return '请求太频繁或额度不足,请稍后重试';
    if (error.type == DioExceptionType.connectionTimeout ||
        error.type == DioExceptionType.receiveTimeout) {
      return '识别请求超时,请检查网络后重试';
    }
    return '图片识别网络请求失败,请检查网络';
  }

  String _inferMimeType(Uint8List bytes) {
    if (bytes.length >= 3 &&
        bytes[0] == 0xFF &&
        bytes[1] == 0xD8 &&
        bytes[2] == 0xFF) {
      return 'image/jpeg';
    }
    if (bytes.length >= 8 &&
        bytes[0] == 0x89 &&
        bytes[1] == 0x50 &&
        bytes[2] == 0x4E &&
        bytes[3] == 0x47) {
      return 'image/png';
    }
    if (bytes.length >= 6 &&
        bytes[0] == 0x47 &&
        bytes[1] == 0x49 &&
        bytes[2] == 0x46 &&
        bytes[3] == 0x38) {
      return 'image/gif';
    }
    if (bytes.length >= 2 && bytes[0] == 0x42 && bytes[1] == 0x4D) {
      return 'image/bmp';
    }
    return 'image/jpeg';
  }
}

class QwenOcrException implements Exception {
  const QwenOcrException(this.code, this.message);

  final Object code;
  final String message;

  @override
  String toString() => 'QwenOcrException($code, $message)';
}