陈昶聿
昨天 51b27082f8fd7ea79143f04b7c3b2dc2a52c3779
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
216
217
218
219
220
221
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 static String apiKey = "sk-712da9346f0940ff909b40dce17579b1";
 
    /** 模型名称 */
    @Value("${qwen.model:qwen-plus}")
    private static String model = "qwen-plus";
 
    /** 接口地址(OpenAI 兼容模式) */
    @Value("${qwen.url:https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions}")
    private static String url = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
 
    /**
     * 判断语音文本最接近哪个选项。
     *
     * @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 questionText
     * @param voiceText 语音识别得到的文本
     * @return 命中的选项原文;无法匹配任一选项时返回 {@code null}
     */
    public static int matchRegex(String questionText, String voiceText, String value, String regexText) {
        if (StringUtils.isBlank(voiceText) || regexText == null || regexText.isEmpty()) {
            return -1;
        }
 
        String systemPrompt = "你是一个专业的语音识别文本语义匹配助手。你的任务是判断用户的语音文本是否在语义上符合给定的<正则匹配规则>或<对应指标值>。"
                + "由于正则表达式无法覆盖所有自然语言的同义表达,你需要作为语义兜底机制,判断语音文本是否表达了与正则规则或指标值相同或相近的核心意图。"
                + "【核心规则】"
                + "1. 如果语音文本在字面上匹配了正则,或者在语义上表达了正则/指标值的意思,请输出:1。"
                + "2. 如果语音文本与正则/指标值的意思完全无关、意思相反或无法推断出相关意图,请输出:0。"
                + "3. 绝对禁止输出任何解释、标点符号、换行符或其他无关字符。你的最终回复只能是一个数字(1 或 0)。"
                ;
        String userPrompt = "请根据以下信息进行语义匹配判断:\n" +
                "- 问题文本:" + questionText + "\n\n"
                + "- 语音识别文本:" + 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 static 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 static JSONObject message(String role, String content) {
        JSONObject msg = new JSONObject();
        msg.put("role", role);
        msg.put("content", content);
        return msg;
    }
 
    /**
     * 从模型回复中提取第一个整数。模型偶尔会回复 “选项2” “2。” 之类,做一次兜底解析。
     */
    private static 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;
        }
    }
}