qwen_chat_client.dart
8.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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 '对话网络请求失败,请检查网络';
}
}