陈昶聿
4 天以前 05c82d89b6df8c236feb0e4dc3f83f18e8414df0
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
package com.smartor.common;
 
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.http.HttpUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
 
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
 
/**
 * 通义千问(Qwen)大模型工具类。
 * <p>
 * 基于阿里云百炼 DashScope 的 OpenAI 兼容接口({@code /compatible-mode/v1/chat/completions}),
 * 主要用于语义匹配:把语音识别得到的自由文本,归一化到一组预设选项中最接近的一个。
 * <p>
 * 配置项(application.yml):
 * <pre>
 * qwen:
 *   api-key: sk-xxxxxxxx          # 百炼 API Key,必填
 *   model: qwen-plus             # 模型名称,默认 qwen-plus
 *   url: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
 * </pre>
 */
@Slf4j
@Component
public class QwenLLMUtil {
 
    /** 百炼 API Key */
    @Value("${qwen.api-key:}")
    private String apiKey;
 
    /** 模型名称 */
    @Value("${qwen.model:qwen-plus}")
    private String model;
 
    /** 接口地址(OpenAI 兼容模式) */
    @Value("${qwen.url:https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions}")
    private String url;
 
    /**
     * 判断语音文本最接近哪个选项。
     *
     * @param voiceText 语音识别得到的文本
     * @param options   候选选项列表
     * @return 命中的选项原文;无法匹配任一选项时返回 {@code null}
     */
    public String matchOption(String voiceText, List<String> options) {
        int index = matchOptionIndex(voiceText, options);
        return index < 0 ? null : options.get(index);
    }
 
    /**
     * 判断语音文本最接近哪个选项,返回选项在列表中的下标。
     *
     * @param voiceText 语音识别得到的文本
     * @param options   候选选项列表
     * @return 命中选项的下标(从 0 开始);无法匹配任一选项时返回 {@code -1}
     */
    public int matchOptionIndex(String voiceText, List<String> options) {
        if (StringUtils.isBlank(voiceText) || options == null || options.isEmpty()) {
            return -1;
        }
        // 只有一个选项时无需调用模型
        if (options.size() == 1) {
            return 0;
        }
 
        StringBuilder optionText = new StringBuilder();
        for (int i = 0; i < options.size(); i++) {
            optionText.append(i + 1).append(". ").append(options.get(i)).append('\n');
        }
 
        String systemPrompt = "你是一个语义匹配助手。用户会给出一段语音识别文本和若干个带编号的选项,"
                + "请判断这段文本在语义上最接近哪一个选项。只允许从给定选项中选择,"
                + "不要做任何解释。直接输出最匹配选项的编号数字;若没有任何选项与文本相关,则输出 0。";
        String userPrompt = "语音文本:" + voiceText + "\n\n选项:\n" + optionText
                + "\n请只输出一个数字(最匹配选项的编号,没有匹配则输出 0)。";
 
        String content = chat(systemPrompt, userPrompt);
        if (StringUtils.isBlank(content)) {
            return -1;
        }
 
        Integer number = extractFirstNumber(content);
        if (number == null || number <= 0 || number > options.size()) {
            log.warn("Qwen 选项匹配未命中,voiceText={}, options={}, modelReturn={}", voiceText, options, content);
            return -1;
        }
        return number - 1;
    }
 
    /**
     * 判断语音文本是否符合这个意思
     *
     * @param voiceText 语音识别得到的文本
     * @return 命中的选项原文;无法匹配任一选项时返回 {@code null}
     */
    public int matchRegex(String voiceText, String value, String regexText) {
        if (StringUtils.isBlank(voiceText) || regexText == null || regexText.isEmpty()) {
            return -1;
        }
 
        String systemPrompt = "你是一个语义匹配助手。用户会给出一段语音识别文本、正则匹配文本、对应指标值"
                + "请判断这段文本是否接近正则匹配规则或者对应指标值的意思"
                + "不要做任何解释。若有相关意思,能匹配的上,直接输出 1;若与文本意思完全不相关,则输出 0。";
        String userPrompt = "语音文本:" + voiceText + "\n\n正则匹配文本:\n" + regexText
                +  "\n\n对应指标值:\n" + value
                + "\n请只输出一个数字(匹配输出 1,没有匹配则输出 0)。";
 
        String content = chat(systemPrompt, userPrompt);
        if (StringUtils.isBlank(content)) {
            return -1;
        }
 
        Integer number = extractFirstNumber(content);
        if (number == null || number <= 0) {
            log.warn("Qwen 选项匹配未命中,voiceText={}, regexText={}, modelReturn={}", voiceText, regexText, content);
            return -1;
        }
        return number - 1;
    }
 
    /**
     * 通用对话调用,返回模型回复的文本内容。
     *
     * @param systemPrompt 系统提示词,可为空
     * @param userPrompt   用户提示词
     * @return 模型回复正文;调用失败返回 {@code null}
     */
    public String chat(String systemPrompt, String userPrompt) {
        if (StringUtils.isBlank(apiKey)) {
            throw new IllegalStateException("通义千问 API Key 未配置(qwen.api-key)");
        }
        if (StringUtils.isBlank(userPrompt)) {
            throw new IllegalArgumentException("userPrompt 不能为空");
        }
 
        JSONArray messages = new JSONArray();
        if (StringUtils.isNotBlank(systemPrompt)) {
            messages.add(message("system", systemPrompt));
        }
        messages.add(message("user", userPrompt));
 
        JSONObject body = new JSONObject();
        body.put("model", model);
        body.put("messages", messages);
        // 匹配场景需要稳定结果,温度调低
        body.put("temperature", 0.01);
 
        Map<String, String> headers = new HashMap<>();
        headers.put("Authorization", "Bearer " + apiKey);
        headers.put("Content-Type", "application/json");
 
        String response = HttpUtils.sendPostByHeader(url, body.toJSONString(), headers);
        if (StringUtils.isBlank(response)) {
            log.error("通义千问返回为空,url={}, body={}", url, body.toJSONString());
            return null;
        }
 
        try {
            JSONObject json = JSONObject.parseObject(response);
            JSONArray choices = json.getJSONArray("choices");
            if (choices == null || choices.isEmpty()) {
                log.error("通义千问响应无 choices,response={}", response);
                return null;
            }
            JSONObject msg = choices.getJSONObject(0).getJSONObject("message");
            return msg == null ? null : StringUtils.trim(msg.getString("content"));
        } catch (Exception e) {
            log.error("解析通义千问响应失败,response={}", response, e);
            return null;
        }
    }
 
    private JSONObject message(String role, String content) {
        JSONObject msg = new JSONObject();
        msg.put("role", role);
        msg.put("content", content);
        return msg;
    }
 
    /**
     * 从模型回复中提取第一个整数。模型偶尔会回复 “选项2” “2。” 之类,做一次兜底解析。
     */
    private Integer extractFirstNumber(String text) {
        List<Character> digits = new ArrayList<>();
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            if (c >= '0' && c <= '9') {
                digits.add(c);
            } else if (!digits.isEmpty()) {
                break;
            }
        }
        if (digits.isEmpty()) {
            return null;
        }
        StringBuilder sb = new StringBuilder();
        for (char c : digits) {
            sb.append(c);
        }
        try {
            return Integer.parseInt(sb.toString());
        } catch (NumberFormatException e) {
            return null;
        }
    }
}