qwen_chat_client.dart 8.16 KB
import 'dart:convert';

import 'package:dio/dio.dart';

/// 阿里云百炼千问文本模型返回的意图识别结果。
class AgentIntentResult {
  const AgentIntentResult({
    required this.intent,
    required this.reply,
    required this.entities,
  });

  /// 意图标识,如 revenue、empty_tables、recent_orders、chat 等。
  final String intent;

  /// 面向用户的一句话回复,要求自然、口语化。
  final String reply;

  /// 意图附带的结构化实体,如 party、table、order_no、time_range。
  final Map<String, dynamic> entities;
}

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

  final Object code;
  final String message;

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

/// 经营助手对话大脑:用千问做自然语言理解、意图识别与友好回复。
///
/// 结构化业务卡片仍在 App 本地生成,LLM 只负责“理解用户 + 说人话”,
/// 这样既能贴近豆包式的自然对话体验,又能保证数据卡片稳定可交互。
class QwenChatClient {
  QwenChatClient({
    required this.apiKey,
    this.model = 'qwen-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: 12),
      receiveTimeout: const Duration(seconds: 30),
      responseType: ResponseType.json,
    ),
  );

  static const List<String> intents = <String>[
    'revenue', // 营业额/经营概览
    'order_count', // 订单量
    'avg_spend', // 人均消费
    'compare', // 同比环比
    'period_stats', // 指定时间段统计
    'empty_tables', // 当前空桌
    'tonight_tables', // 今晚空桌
    'table_by_party', // 指定人数可用桌位
    'table_status', // 桌位状态
    'today_bookings', // 今日预订
    'recent_bookings', // 近期预订
    'confirmed_bookings', // 已确认预订
    'unconfirmed_bookings', // 未确认预订
    'booking_detail', // 预订详情
    'recent_orders', // 近期订单
    'order_by_no', // 指定订单
    'order_by_table', // 指定桌位订单
    'pay_status', // 支付状态
    'hot_dishes', // 热销菜品
    'dish_sales_rank', // 销量排行
    'dish_amount_rank', // 销售额排行
    'slow_dishes', // 滞销菜品
    'booking', // 用户想录入一条预订
    'chat', // 闲聊或无法归类
  ];

  String _systemPrompt(String storeName, String today) {
    return '你是「$storeName」餐厅的 AI 经营助手,名字叫“小周”,语气专业但亲切、像靠谱的店长助理。'
        '今天是 $today。'
        '你能帮老板查询经营数据、桌位情况、预订情况、订单查询和菜品销售情况,并支持连续追问(结合上文继承门店、日期和查询条件)。\n\n'
        '你的能力范围:\n'
        '1. 经营数据:营业额、订单量、人均消费、指定时间段统计、同比环比;\n'
        '2. 桌位情况:当前空桌、今晚空桌、指定人数可用桌位、桌位状态;\n'
        '3. 预订情况:今日/近期预订、已确认、未确认、预订详情;\n'
        '4. 订单查询:近期订单、指定订单、指定桌位订单、支付状态;\n'
        '5. 菜品销售:热销菜品、销量排行、销售额排行、滞销菜品。\n\n'
        '请结合对话历史理解用户的连续追问(例如用户先问“今天营业额”,再说“那昨天呢”,应理解为查昨天营业额)。'
        '只输出一个 JSON 对象,不要输出任何解释、Markdown 代码块或多余文字。\n\n'
        'JSON 字段:\n'
        '- intent: 用户意图,必须是下面列表中的一个值;\n'
        '${intents.map((String i) => '- $i').join('\n')}\n'
        '- reply: 给用户的一句话回复。要自然、口语化,像真人在回复;业务类回复请包含合理的具体数字和一句可执行的建议;语气不要像客服模板。\n'
        '- entities: 提取到的实体对象,字段可为 party(人数,数字)、table(桌号/包间号)、order_no(订单号)、time_range(如“今天/今晚/昨天/近7天”)。没有则为空对象。\n\n'
        '输出示例:\n'
        '{"intent":"tonight_tables","reply":"今晚 20 点大概还有 8 张空桌,其中 2 桌是待确认状态,建议有客人先锁 A12 或 C03。","entities":{"time_range":"今晚"}}';
  }

  /// 调用千问进行意图识别与回复生成。
  ///
  /// [history] 为早前的对话(role: user/assistant,content: 文本)。
  /// 失败时抛出 [QwenChatException]。
  Future<AgentIntentResult> chat({
    required String storeName,
    required List<Map<String, String>> history,
    required String question,
  }) async {
    final DateTime now = DateTime.now();
    final String today = '${now.year}-${now.month.toString().padLeft(2, '0')}-'
        '${now.day.toString().padLeft(2, '0')}';

    final List<Map<String, String>> messages = <Map<String, String>>[
      <String, String>{
        'role': 'system',
        'content': _systemPrompt(storeName, today)
      },
      ...history,
      <String, String>{'role': 'user', 'content': question},
    ];

    final Response<Map<String, dynamic>> response;
    try {
      response = await _dio.post<Map<String, dynamic>>(
        _endpoint,
        data: <String, dynamic>{
          'model': model,
          'temperature': 0.5,
          'messages': messages,
        },
        options: Options(
          headers: <String, String>{
            'Authorization': 'Bearer $apiKey',
            'Content-Type': 'application/json',
          },
        ),
      );
    } on DioException catch (error) {
      throw QwenChatException(
        error.response?.statusCode ?? 0,
        _friendlyDioError(error),
      );
    }

    final Map<String, dynamic>? data = response.data;
    if (data == null) {
      throw const QwenChatException(0, '对话返回数据异常');
    }

    final dynamic error = data['error'];
    if (error is Map) {
      throw QwenChatException(
        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 QwenChatException(0, '未返回有效回复');
    }

    final String content = (message['content'] ?? '').toString().trim();
    final Map<String, dynamic>? json = _parseJsonObject(content);
    if (json == null) {
      // 模型没按要求返回 JSON 时,直接把内容当闲聊回复。
      return AgentIntentResult(
        intent: 'chat',
        reply: content,
        entities: const <String, dynamic>{},
      );
    }

    return AgentIntentResult(
      intent: (json['intent'] ?? 'chat').toString().trim().toLowerCase(),
      reply: (json['reply'] ?? content).toString().trim(),
      entities: json['entities'] is Map
          ? Map<String, dynamic>.from(json['entities'] as Map)
          : const <String, dynamic>{},
    );
  }

  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));
      return decoded is Map<String, dynamic> ? decoded : null;
    } catch (_) {
      return null;
    }
  }

  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 '对话网络请求失败,请检查网络';
  }
}