feat: add booking agent provider adapters
This commit is contained in:
parent
d99b6d6e6d
commit
77c6d6923b
@ -22,6 +22,9 @@
|
|||||||
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
|
| `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret |
|
||||||
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
|
| `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 |
|
||||||
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期;生产限制为 300~2592000 秒 |
|
| `PETSTORE_SESSION_TTL_SECONDS` | 否 | `604800`(7 天) | session token 有效期;生产限制为 300~2592000 秒 |
|
||||||
|
| `PETSTORE_BOOKING_AGENT_ENABLED` | 否 | `false` | 智能预约 M0 总开关;默认关闭 |
|
||||||
|
| `PETSTORE_BOOKING_AGENT_LLM_*` | 开启时必填 | 固定模型/3s 超时 | `PROVIDER/BASE_URL/MODEL/API_KEY/TIMEOUT_MS`,意图提取逻辑配置 |
|
||||||
|
| `PETSTORE_BOOKING_AGENT_ASR_*` | 开启时必填 | 固定模型/5s 超时 | `PROVIDER/BASE_URL/MODEL/API_KEY/TIMEOUT_MS`,语音转写逻辑配置 |
|
||||||
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL(用于生成媒体绝对 URL) |
|
| `APP_BASE_URL` | 生产必填 | `http://localhost:8080` | 后端对外可访问 base URL(用于生成媒体绝对 URL) |
|
||||||
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
|
| `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 |
|
||||||
| `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 |
|
| `UPLOAD_PATH` | 生产必填 | `/www/petstore/uploads` | 上传目录绝对路径;服务账号需可读写 |
|
||||||
@ -40,6 +43,8 @@ CORS 只由全局 `CorsFilter` 和 `CORS_ALLOWED_ORIGINS` 控制;Controller
|
|||||||
|
|
||||||
Session token 的 `exp` 为必填 Unix 秒时间戳;缺失、非数字、非正或已到期均 fail-closed。非正 TTL 不再静默回退,生产 TTL 只能为 5 分钟至 30 天。
|
Session token 的 `exp` 为必填 Unix 秒时间戳;缺失、非数字、非正或已到期均 fail-closed。非正 TTL 不再静默回退,生产 TTL 只能为 5 分钟至 30 天。
|
||||||
|
|
||||||
|
智能预约 provider 仅允许 HTTPS base URL(测试可使用 loopback HTTP),不自动重试,不记录输入原文、音频、外部响应或密钥。开关关闭或配置缺失时 provider fail closed,不影响普通预约和应用启动。
|
||||||
|
|
||||||
> ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。
|
> ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|||||||
@ -13,6 +13,19 @@ PETSTORE_SESSION_SECRET=
|
|||||||
PETSTORE_SESSION_TTL_SECONDS=604800
|
PETSTORE_SESSION_TTL_SECONDS=604800
|
||||||
SMS_UNIVERSAL_CODE=
|
SMS_UNIVERSAL_CODE=
|
||||||
|
|
||||||
|
# 智能预约 M0 默认关闭。真实 Workspace URL/API Key 只写入服务器 0600 EnvironmentFile。
|
||||||
|
PETSTORE_BOOKING_AGENT_ENABLED=false
|
||||||
|
PETSTORE_BOOKING_AGENT_LLM_PROVIDER=aliyun
|
||||||
|
PETSTORE_BOOKING_AGENT_LLM_BASE_URL=
|
||||||
|
PETSTORE_BOOKING_AGENT_LLM_MODEL=qwen-plus-2025-12-01
|
||||||
|
PETSTORE_BOOKING_AGENT_LLM_API_KEY=
|
||||||
|
PETSTORE_BOOKING_AGENT_LLM_TIMEOUT_MS=3000
|
||||||
|
PETSTORE_BOOKING_AGENT_ASR_PROVIDER=aliyun
|
||||||
|
PETSTORE_BOOKING_AGENT_ASR_BASE_URL=
|
||||||
|
PETSTORE_BOOKING_AGENT_ASR_MODEL=qwen3-asr-flash-2026-02-10
|
||||||
|
PETSTORE_BOOKING_AGENT_ASR_API_KEY=
|
||||||
|
PETSTORE_BOOKING_AGENT_ASR_TIMEOUT_MS=5000
|
||||||
|
|
||||||
APP_BASE_URL=https://api.petstore.invalid
|
APP_BASE_URL=https://api.petstore.invalid
|
||||||
CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid
|
CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid
|
||||||
UPLOAD_PATH=/var/lib/petstore/uploads
|
UPLOAD_PATH=/var/lib/petstore/uploads
|
||||||
|
|||||||
@ -0,0 +1,89 @@
|
|||||||
|
package com.petstore.bookingagent.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能预约外部供应商配置。
|
||||||
|
*
|
||||||
|
* <p>属性在启动时不强制完整:功能默认关闭,未配置密钥时普通预约仍应正常启动。
|
||||||
|
* 真正调用 provider 前由适配器 fail closed。
|
||||||
|
*/
|
||||||
|
@ConfigurationProperties(prefix = "app.booking-agent")
|
||||||
|
public class BookingAgentProperties {
|
||||||
|
|
||||||
|
private boolean enabled;
|
||||||
|
private final Provider llm = new Provider("qwen-plus-2025-12-01", 3000);
|
||||||
|
private final Provider asr = new Provider("qwen3-asr-flash-2026-02-10", 5000);
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Provider getLlm() {
|
||||||
|
return llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Provider getAsr() {
|
||||||
|
return asr;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Provider {
|
||||||
|
private String provider = "aliyun";
|
||||||
|
private String baseUrl = "";
|
||||||
|
private String model;
|
||||||
|
private String apiKey = "";
|
||||||
|
private int timeoutMs;
|
||||||
|
|
||||||
|
public Provider() {
|
||||||
|
}
|
||||||
|
|
||||||
|
Provider(String model, int timeoutMs) {
|
||||||
|
this.model = model;
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getProvider() {
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setProvider(String provider) {
|
||||||
|
this.provider = provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBaseUrl() {
|
||||||
|
return baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBaseUrl(String baseUrl) {
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getModel() {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setModel(String model) {
|
||||||
|
this.model = model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getApiKey() {
|
||||||
|
return apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setApiKey(String apiKey) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTimeoutMs() {
|
||||||
|
return timeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTimeoutMs(int timeoutMs) {
|
||||||
|
this.timeoutMs = timeoutMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.petstore.bookingagent.config;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentExtractor;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentValidator;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscriber;
|
||||||
|
import com.petstore.bookingagent.provider.aliyun.Qwen3AsrSpeechTranscriber;
|
||||||
|
import com.petstore.bookingagent.provider.aliyun.QwenBookingIntentExtractor;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(BookingAgentProperties.class)
|
||||||
|
public class BookingAgentProviderConfiguration {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
BookingIntentValidator bookingIntentValidator(ObjectMapper objectMapper) {
|
||||||
|
return new BookingIntentValidator(objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean("bookingAgentHttpClient")
|
||||||
|
HttpClient bookingAgentHttpClient() {
|
||||||
|
return HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(3))
|
||||||
|
.followRedirects(HttpClient.Redirect.NEVER)
|
||||||
|
.version(HttpClient.Version.HTTP_1_1)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
BookingIntentExtractor bookingIntentExtractor(
|
||||||
|
BookingAgentProperties properties,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
BookingIntentValidator validator,
|
||||||
|
@Qualifier("bookingAgentHttpClient") HttpClient httpClient) {
|
||||||
|
return new QwenBookingIntentExtractor(properties, objectMapper, validator, httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SpeechTranscriber speechTranscriber(
|
||||||
|
BookingAgentProperties properties,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Qualifier("bookingAgentHttpClient") HttpClient httpClient) {
|
||||||
|
return new Qwen3AsrSpeechTranscriber(properties, objectMapper, httpClient);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
public interface BookingIntentExtractor {
|
||||||
|
|
||||||
|
BookingIntentPatch extract(BookingIntentRequest request);
|
||||||
|
}
|
||||||
@ -0,0 +1,107 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record BookingIntentPatch(
|
||||||
|
String schemaVersion,
|
||||||
|
Intent intent,
|
||||||
|
String petQuery,
|
||||||
|
String serviceQuery,
|
||||||
|
String dateExpression,
|
||||||
|
TimeWindow timeWindow,
|
||||||
|
String remark,
|
||||||
|
List<ClearField> clearFields,
|
||||||
|
List<Ambiguity> ambiguities,
|
||||||
|
NextAction nextAction) {
|
||||||
|
|
||||||
|
public static final String SCHEMA_VERSION = "booking-intent-v1";
|
||||||
|
|
||||||
|
public record TimeWindow(String start, String end) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Intent implements WireValue {
|
||||||
|
BOOK("book"),
|
||||||
|
MODIFY("modify"),
|
||||||
|
END("end"),
|
||||||
|
FALLBACK("fallback");
|
||||||
|
|
||||||
|
private final String wireValue;
|
||||||
|
|
||||||
|
Intent(String wireValue) {
|
||||||
|
this.wireValue = wireValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@JsonValue
|
||||||
|
public String wireValue() {
|
||||||
|
return wireValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ClearField implements WireValue {
|
||||||
|
PET_QUERY("petQuery"),
|
||||||
|
SERVICE_QUERY("serviceQuery"),
|
||||||
|
DATE_EXPRESSION("dateExpression"),
|
||||||
|
TIME_WINDOW("timeWindow"),
|
||||||
|
REMARK("remark");
|
||||||
|
|
||||||
|
private final String wireValue;
|
||||||
|
|
||||||
|
ClearField(String wireValue) {
|
||||||
|
this.wireValue = wireValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@JsonValue
|
||||||
|
public String wireValue() {
|
||||||
|
return wireValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Ambiguity implements WireValue {
|
||||||
|
PET("pet"),
|
||||||
|
SERVICE("service"),
|
||||||
|
DATE("date"),
|
||||||
|
TIME("time"),
|
||||||
|
REMARK("remark");
|
||||||
|
|
||||||
|
private final String wireValue;
|
||||||
|
|
||||||
|
Ambiguity(String wireValue) {
|
||||||
|
this.wireValue = wireValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@JsonValue
|
||||||
|
public String wireValue() {
|
||||||
|
return wireValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NextAction implements WireValue {
|
||||||
|
ASK("ask"),
|
||||||
|
RESOLVE_CONTEXT("resolve_context"),
|
||||||
|
SEARCH_SLOTS("search_slots"),
|
||||||
|
SHOW_DRAFT("show_draft"),
|
||||||
|
FALLBACK("fallback"),
|
||||||
|
END("end");
|
||||||
|
|
||||||
|
private final String wireValue;
|
||||||
|
|
||||||
|
NextAction(String wireValue) {
|
||||||
|
this.wireValue = wireValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@JsonValue
|
||||||
|
public String wireValue() {
|
||||||
|
return wireValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WireValue {
|
||||||
|
String wireValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
public record BookingIntentRequest(String text, BookingIntentPatch currentIntent) {
|
||||||
|
|
||||||
|
private static final int MAX_TEXT_LENGTH = 500;
|
||||||
|
|
||||||
|
public BookingIntentRequest {
|
||||||
|
text = normalize(text);
|
||||||
|
if (text == null
|
||||||
|
|| text.length() > MAX_TEXT_LENGTH
|
||||||
|
|| SensitiveTextGuard.containsForbiddenValue(text)) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
StringBuilder normalized = new StringBuilder(value.length());
|
||||||
|
value.strip().codePoints()
|
||||||
|
.filter(codePoint -> !Character.isISOControl(codePoint))
|
||||||
|
.forEach(normalized::appendCodePoint);
|
||||||
|
String result = normalized.toString().strip();
|
||||||
|
return result.isEmpty() ? null : result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,188 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch.Ambiguity;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch.ClearField;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch.Intent;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch.NextAction;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch.TimeWindow;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/** 与 docs/contracts/booking-intent-v1.schema.json 对齐的服务端强校验。 */
|
||||||
|
public class BookingIntentValidator {
|
||||||
|
|
||||||
|
private static final int MAX_RESPONSE_LENGTH = 16 * 1024;
|
||||||
|
private static final Pattern HH_MM = Pattern.compile("^(?:[01]\\d|2[0-3]):[0-5]\\d$");
|
||||||
|
private static final Set<String> ROOT_FIELDS = Set.of(
|
||||||
|
"schemaVersion",
|
||||||
|
"intent",
|
||||||
|
"petQuery",
|
||||||
|
"serviceQuery",
|
||||||
|
"dateExpression",
|
||||||
|
"timeWindow",
|
||||||
|
"remark",
|
||||||
|
"clearFields",
|
||||||
|
"ambiguities",
|
||||||
|
"nextAction"
|
||||||
|
);
|
||||||
|
private static final Set<String> TIME_WINDOW_FIELDS = Set.of("start", "end");
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public BookingIntentValidator(ObjectMapper objectMapper) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BookingIntentPatch parseAndValidate(String content) {
|
||||||
|
if (content == null || content.isBlank() || content.length() > MAX_RESPONSE_LENGTH) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
JsonNode root;
|
||||||
|
try {
|
||||||
|
root = objectMapper.reader()
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||||
|
.readTree(content);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
if (root == null || !root.isObject() || !fieldNames(root).equals(ROOT_FIELDS)) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
String schemaVersion = requiredText(root, "schemaVersion", 64);
|
||||||
|
if (!BookingIntentPatch.SCHEMA_VERSION.equals(schemaVersion)) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BookingIntentPatch(
|
||||||
|
schemaVersion,
|
||||||
|
enumValue(root, "intent", Intent.values()),
|
||||||
|
nullableText(root, "petQuery", 64),
|
||||||
|
nullableText(root, "serviceQuery", 64),
|
||||||
|
nullableText(root, "dateExpression", 32),
|
||||||
|
timeWindow(root.get("timeWindow")),
|
||||||
|
nullableText(root, "remark", 200),
|
||||||
|
enumList(root.get("clearFields"), 5, ClearField.values()),
|
||||||
|
enumList(root.get("ambiguities"), 5, Ambiguity.values()),
|
||||||
|
enumValue(root, "nextAction", NextAction.values())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimeWindow timeWindow(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!node.isObject() || !fieldNames(node).equals(TIME_WINDOW_FIELDS)) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
String start = nullableTime(node.get("start"));
|
||||||
|
String end = nullableTime(node.get("end"));
|
||||||
|
return new TimeWindow(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullableTime(JsonNode node) {
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!node.isTextual()) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
String value = normalize(node.textValue());
|
||||||
|
if (value == null || !HH_MM.matcher(value).matches()) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String requiredText(JsonNode root, String field, int maxLength) {
|
||||||
|
String value = nullableText(root, field, maxLength);
|
||||||
|
if (value == null) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nullableText(JsonNode root, String field, int maxLength) {
|
||||||
|
JsonNode node = root.get(field);
|
||||||
|
if (node == null || node.isNull()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!node.isTextual()) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
String value = normalize(node.textValue());
|
||||||
|
if (value == null
|
||||||
|
|| value.length() > maxLength
|
||||||
|
|| SensitiveTextGuard.containsForbiddenValue(value)) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <E extends Enum<E> & BookingIntentPatch.WireValue> E enumValue(
|
||||||
|
JsonNode root,
|
||||||
|
String field,
|
||||||
|
E[] values) {
|
||||||
|
String raw = requiredText(root, field, 64);
|
||||||
|
return findEnum(raw, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <E extends Enum<E> & BookingIntentPatch.WireValue> List<E> enumList(
|
||||||
|
JsonNode node,
|
||||||
|
int maxItems,
|
||||||
|
E[] values) {
|
||||||
|
if (node == null || !node.isArray() || node.size() > maxItems) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
List<E> result = new ArrayList<>(node.size());
|
||||||
|
Set<E> unique = new HashSet<>();
|
||||||
|
for (JsonNode item : node) {
|
||||||
|
if (!item.isTextual()) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
E parsed = findEnum(item.textValue(), values);
|
||||||
|
if (!unique.add(parsed)) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
result.add(parsed);
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <E extends Enum<E> & BookingIntentPatch.WireValue> E findEnum(String raw, E[] values) {
|
||||||
|
for (E value : values) {
|
||||||
|
if (value.wireValue().equals(raw)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<String> fieldNames(JsonNode node) {
|
||||||
|
Set<String> names = new HashSet<>();
|
||||||
|
Iterator<String> iterator = node.fieldNames();
|
||||||
|
iterator.forEachRemaining(names::add);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalize(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
StringBuilder normalized = new StringBuilder(value.length());
|
||||||
|
value.strip().codePoints()
|
||||||
|
.filter(codePoint -> !Character.isISOControl(codePoint))
|
||||||
|
.forEach(normalized::appendCodePoint);
|
||||||
|
String result = normalized.toString().strip();
|
||||||
|
return result.isEmpty() ? null : result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
/** 供应商层只暴露可映射的失败类型,不携带外部响应原文或请求载荷。 */
|
||||||
|
public class ProviderException extends RuntimeException {
|
||||||
|
|
||||||
|
public enum Reason {
|
||||||
|
DISABLED,
|
||||||
|
CONFIGURATION,
|
||||||
|
INVALID_INPUT,
|
||||||
|
TIMEOUT,
|
||||||
|
UPSTREAM,
|
||||||
|
INVALID_RESPONSE
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Reason reason;
|
||||||
|
private final Integer httpStatus;
|
||||||
|
|
||||||
|
private ProviderException(Reason reason, Integer httpStatus) {
|
||||||
|
super(reason.name());
|
||||||
|
this.reason = reason;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException disabled() {
|
||||||
|
return new ProviderException(Reason.DISABLED, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException configuration() {
|
||||||
|
return new ProviderException(Reason.CONFIGURATION, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException invalidInput() {
|
||||||
|
return new ProviderException(Reason.INVALID_INPUT, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException timeout() {
|
||||||
|
return new ProviderException(Reason.TIMEOUT, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException upstream(int httpStatus) {
|
||||||
|
return new ProviderException(Reason.UPSTREAM, httpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException upstream() {
|
||||||
|
return new ProviderException(Reason.UPSTREAM, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ProviderException invalidResponse() {
|
||||||
|
return new ProviderException(Reason.INVALID_RESPONSE, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Reason getReason() {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getHttpStatus() {
|
||||||
|
return httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isRetryable() {
|
||||||
|
return reason == Reason.TIMEOUT || reason == Reason.UPSTREAM;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public final class SensitiveTextGuard {
|
||||||
|
|
||||||
|
private static final Pattern MAINLAND_MOBILE = Pattern.compile("(?<!\\d)1[3-9]\\d{9}(?!\\d)");
|
||||||
|
private static final Pattern URL_OR_CREDENTIAL = Pattern.compile(
|
||||||
|
"(?i)(?:https?://|bearer\\s+[a-z0-9._~-]+|(?:report|access|session)[_-]?token\\s*[:=]|api[_ -]?key\\s*[:=])"
|
||||||
|
);
|
||||||
|
private static final Pattern JWT = Pattern.compile(
|
||||||
|
"(?i)(?<![a-z0-9_-])eyJ[a-z0-9_-]{8,}\\.[a-z0-9_-]{8,}\\.[a-z0-9_-]{8,}(?![a-z0-9_-])"
|
||||||
|
);
|
||||||
|
|
||||||
|
private SensitiveTextGuard() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean containsForbiddenValue(String value) {
|
||||||
|
return value != null && (
|
||||||
|
MAINLAND_MOBILE.matcher(value).find()
|
||||||
|
|| URL_OR_CREDENTIAL.matcher(value).find()
|
||||||
|
|| JWT.matcher(value).find()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
public interface SpeechTranscriber {
|
||||||
|
|
||||||
|
SpeechTranscription transcribe(SpeechTranscriptionRequest request);
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
public record SpeechTranscription(String text) {
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record SpeechTranscriptionRequest(byte[] audio, String mimeType, List<String> contextTerms) {
|
||||||
|
|
||||||
|
public SpeechTranscriptionRequest {
|
||||||
|
audio = audio == null ? null : audio.clone();
|
||||||
|
try {
|
||||||
|
contextTerms = contextTerms == null ? List.of() : List.copyOf(contextTerms);
|
||||||
|
} catch (NullPointerException exception) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] audio() {
|
||||||
|
return audio == null ? null : audio.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
package com.petstore.bookingagent.provider.aliyun;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.config.BookingAgentProperties;
|
||||||
|
import com.petstore.bookingagent.provider.ProviderException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.net.http.HttpTimeoutException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
final class OpenAiCompatibleTransport {
|
||||||
|
|
||||||
|
private static final int MAX_RESPONSE_BYTES = 64 * 1024;
|
||||||
|
private static final Set<String> LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1", "[::1]");
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
OpenAiCompatibleTransport(ObjectMapper objectMapper, HttpClient httpClient) {
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
String postChatCompletion(BookingAgentProperties.Provider provider, Object requestBody) {
|
||||||
|
URI endpoint = endpoint(provider);
|
||||||
|
byte[] requestBytes;
|
||||||
|
try {
|
||||||
|
requestBytes = objectMapper.writeValueAsBytes(requestBody);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(endpoint)
|
||||||
|
.timeout(Duration.ofMillis(provider.getTimeoutMs()))
|
||||||
|
.header("Authorization", "Bearer " + provider.getApiKey().strip())
|
||||||
|
.header("Content-Type", "application/json; charset=UTF-8")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("User-Agent", "PetstoreBookingAgent/0.1")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofByteArray(requestBytes))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<byte[]> response;
|
||||||
|
try {
|
||||||
|
response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||||
|
} catch (HttpTimeoutException exception) {
|
||||||
|
throw ProviderException.timeout();
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw ProviderException.timeout();
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw ProviderException.upstream();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||||
|
throw ProviderException.upstream(response.statusCode());
|
||||||
|
}
|
||||||
|
byte[] body = response.body();
|
||||||
|
if (body == null || body.length == 0 || body.length > MAX_RESPONSE_BYTES) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root;
|
||||||
|
try {
|
||||||
|
root = objectMapper.reader()
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
|
||||||
|
.readTree(new String(body, StandardCharsets.UTF_8));
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
JsonNode choice = root.path("choices").path(0);
|
||||||
|
if (!"stop".equals(choice.path("finish_reason").asText())) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
JsonNode content = choice.path("message").path("content");
|
||||||
|
if (!content.isTextual() || content.textValue().isBlank()) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
return content.textValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void requireAvailable(boolean enabled, BookingAgentProperties.Provider provider) {
|
||||||
|
if (!enabled) {
|
||||||
|
throw ProviderException.disabled();
|
||||||
|
}
|
||||||
|
if (provider == null
|
||||||
|
|| !"aliyun".equalsIgnoreCase(normalize(provider.getProvider()))
|
||||||
|
|| isBlank(provider.getBaseUrl())
|
||||||
|
|| isBlank(provider.getModel())
|
||||||
|
|| isPlaceholder(provider.getApiKey())
|
||||||
|
|| provider.getTimeoutMs() < 50
|
||||||
|
|| provider.getTimeoutMs() > 30_000) {
|
||||||
|
throw ProviderException.configuration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static URI endpoint(BookingAgentProperties.Provider provider) {
|
||||||
|
String baseUrl = provider.getBaseUrl().strip();
|
||||||
|
String value = baseUrl.endsWith("/chat/completions")
|
||||||
|
? baseUrl
|
||||||
|
: baseUrl.replaceAll("/+$", "") + "/chat/completions";
|
||||||
|
URI uri;
|
||||||
|
try {
|
||||||
|
uri = URI.create(value);
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw ProviderException.configuration();
|
||||||
|
}
|
||||||
|
String scheme = normalize(uri.getScheme());
|
||||||
|
String host = normalize(uri.getHost());
|
||||||
|
boolean localHttp = "http".equals(scheme) && LOOPBACK_HOSTS.contains(host);
|
||||||
|
if (!("https".equals(scheme) || localHttp)
|
||||||
|
|| host == null
|
||||||
|
|| uri.getUserInfo() != null
|
||||||
|
|| uri.getQuery() != null
|
||||||
|
|| uri.getFragment() != null) {
|
||||||
|
throw ProviderException.configuration();
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isPlaceholder(String value) {
|
||||||
|
if (isBlank(value)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String normalized = value.strip().toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.startsWith("<")
|
||||||
|
|| normalized.contains("from-secret-store")
|
||||||
|
|| normalized.contains("change-me")
|
||||||
|
|| normalized.contains("your_api_key");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalize(String value) {
|
||||||
|
return value == null ? null : value.strip().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,140 @@
|
|||||||
|
package com.petstore.bookingagent.provider.aliyun;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.config.BookingAgentProperties;
|
||||||
|
import com.petstore.bookingagent.provider.ProviderException;
|
||||||
|
import com.petstore.bookingagent.provider.SensitiveTextGuard;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscriber;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscription;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscriptionRequest;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class Qwen3AsrSpeechTranscriber implements SpeechTranscriber {
|
||||||
|
|
||||||
|
static final int MAX_AUDIO_BYTES = 3 * 1024 * 1024;
|
||||||
|
private static final int MAX_TRANSCRIPT_LENGTH = 500;
|
||||||
|
private static final int MAX_CONTEXT_TERMS = 30;
|
||||||
|
private static final int MAX_CONTEXT_TERM_LENGTH = 64;
|
||||||
|
private static final int MAX_CONTEXT_LENGTH = 1000;
|
||||||
|
private static final Set<String> ALLOWED_MIME_TYPES = Set.of(
|
||||||
|
"audio/aac",
|
||||||
|
"audio/amr",
|
||||||
|
"audio/mpeg",
|
||||||
|
"audio/ogg",
|
||||||
|
"audio/opus",
|
||||||
|
"audio/wav",
|
||||||
|
"audio/webm",
|
||||||
|
"video/webm"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final BookingAgentProperties properties;
|
||||||
|
private final OpenAiCompatibleTransport transport;
|
||||||
|
|
||||||
|
public Qwen3AsrSpeechTranscriber(
|
||||||
|
BookingAgentProperties properties,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
HttpClient httpClient) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.transport = new OpenAiCompatibleTransport(objectMapper, httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SpeechTranscription transcribe(SpeechTranscriptionRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
BookingAgentProperties.Provider provider = properties.getAsr();
|
||||||
|
OpenAiCompatibleTransport.requireAvailable(properties.isEnabled(), provider);
|
||||||
|
|
||||||
|
byte[] audio = request.audio();
|
||||||
|
String mimeType = normalizeMimeType(request.mimeType());
|
||||||
|
if (audio == null
|
||||||
|
|| audio.length == 0
|
||||||
|
|| audio.length > MAX_AUDIO_BYTES
|
||||||
|
|| !ALLOWED_MIME_TYPES.contains(mimeType)) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> messages = new ArrayList<>();
|
||||||
|
String context = contextMessage(request.contextTerms());
|
||||||
|
if (!context.isEmpty()) {
|
||||||
|
messages.add(Map.of("role", "system", "content", context));
|
||||||
|
}
|
||||||
|
String dataUrl = "data:" + mimeType + ";base64," + Base64.getEncoder().encodeToString(audio);
|
||||||
|
messages.add(Map.of(
|
||||||
|
"role", "user",
|
||||||
|
"content", List.of(Map.of(
|
||||||
|
"type", "input_audio",
|
||||||
|
"input_audio", Map.of("data", dataUrl)
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("model", provider.getModel().strip());
|
||||||
|
body.put("messages", messages);
|
||||||
|
body.put("stream", false);
|
||||||
|
body.put("asr_options", Map.of("enable_itn", true));
|
||||||
|
|
||||||
|
String content = normalizeText(transport.postChatCompletion(provider, body));
|
||||||
|
if (content == null || content.length() > MAX_TRANSCRIPT_LENGTH) {
|
||||||
|
throw ProviderException.invalidResponse();
|
||||||
|
}
|
||||||
|
return new SpeechTranscription(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String contextMessage(List<String> terms) {
|
||||||
|
if (terms == null || terms.isEmpty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (terms.size() > MAX_CONTEXT_TERMS) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
List<String> sanitized = new ArrayList<>(terms.size());
|
||||||
|
for (String term : terms) {
|
||||||
|
String normalized = normalizeText(term);
|
||||||
|
if (normalized == null
|
||||||
|
|| normalized.length() > MAX_CONTEXT_TERM_LENGTH
|
||||||
|
|| SensitiveTextGuard.containsForbiddenValue(normalized)) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
sanitized.add(normalized);
|
||||||
|
}
|
||||||
|
String context = "背景词仅用于语音识别,可能包含宠物名和当前门店服务名:" + String.join("、", sanitized);
|
||||||
|
if (context.length() > MAX_CONTEXT_LENGTH) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeMimeType(String mimeType) {
|
||||||
|
if (mimeType == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = mimeType.strip().toLowerCase(Locale.ROOT);
|
||||||
|
return switch (normalized) {
|
||||||
|
case "audio/x-wav" -> "audio/wav";
|
||||||
|
case "audio/mp3" -> "audio/mpeg";
|
||||||
|
default -> normalized;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeText(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
StringBuilder normalized = new StringBuilder(value.length());
|
||||||
|
value.strip().codePoints()
|
||||||
|
.filter(codePoint -> !Character.isISOControl(codePoint))
|
||||||
|
.forEach(normalized::appendCodePoint);
|
||||||
|
String result = normalized.toString().strip();
|
||||||
|
return result.isEmpty() ? null : result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
package com.petstore.bookingagent.provider.aliyun;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.config.BookingAgentProperties;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentExtractor;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentRequest;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentValidator;
|
||||||
|
import com.petstore.bookingagent.provider.ProviderException;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class QwenBookingIntentExtractor implements BookingIntentExtractor {
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT = """
|
||||||
|
你是宠物门店预约意图提取器。只输出一个 JSON object,不要 Markdown、解释或额外文字。
|
||||||
|
必须恰好包含字段:schemaVersion,intent,petQuery,serviceQuery,dateExpression,timeWindow,remark,clearFields,ambiguities,nextAction。
|
||||||
|
schemaVersion 固定 booking-intent-v1。
|
||||||
|
intent 只能为 book,modify,end,fallback。
|
||||||
|
nextAction 只能为 ask,resolve_context,search_slots,show_draft,fallback,end。
|
||||||
|
clearFields 只能包含 petQuery,serviceQuery,dateExpression,timeWindow,remark。
|
||||||
|
ambiguities 只能包含 pet,service,date,time,remark。
|
||||||
|
timeWindow 为 null 或对象,例如 {"start":"15:00","end":null};start/end 各自只能是 HH:mm 字符串或 null。
|
||||||
|
不得输出业务 ID、价格、号源、预约状态、门店或宠物归属事实。无法确定时用 null 和 ambiguities,不要猜测。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final BookingAgentProperties properties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final BookingIntentValidator validator;
|
||||||
|
private final OpenAiCompatibleTransport transport;
|
||||||
|
|
||||||
|
public QwenBookingIntentExtractor(
|
||||||
|
BookingAgentProperties properties,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
BookingIntentValidator validator,
|
||||||
|
HttpClient httpClient) {
|
||||||
|
this.properties = properties;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.validator = validator;
|
||||||
|
this.transport = new OpenAiCompatibleTransport(objectMapper, httpClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BookingIntentPatch extract(BookingIntentRequest request) {
|
||||||
|
if (request == null) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
BookingAgentProperties.Provider provider = properties.getLlm();
|
||||||
|
OpenAiCompatibleTransport.requireAvailable(properties.isEnabled(), provider);
|
||||||
|
|
||||||
|
Map<String, Object> userPayload = new LinkedHashMap<>();
|
||||||
|
userPayload.put("currentIntent", request.currentIntent());
|
||||||
|
userPayload.put("text", request.text());
|
||||||
|
|
||||||
|
String userContent;
|
||||||
|
try {
|
||||||
|
userContent = objectMapper.writeValueAsString(userPayload);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw ProviderException.invalidInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new LinkedHashMap<>();
|
||||||
|
body.put("model", provider.getModel().strip());
|
||||||
|
body.put("messages", List.of(
|
||||||
|
Map.of("role", "system", "content", SYSTEM_PROMPT),
|
||||||
|
Map.of("role", "user", "content", userContent)
|
||||||
|
));
|
||||||
|
body.put("response_format", Map.of("type", "json_object"));
|
||||||
|
body.put("stream", false);
|
||||||
|
body.put("enable_thinking", false);
|
||||||
|
body.put("temperature", 0.1);
|
||||||
|
body.put("max_completion_tokens", 800);
|
||||||
|
|
||||||
|
String content = transport.postChatCompletion(provider, body);
|
||||||
|
return validator.parseAndValidate(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -52,6 +52,21 @@ app:
|
|||||||
preflight-only: false
|
preflight-only: false
|
||||||
demo:
|
demo:
|
||||||
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
||||||
|
booking-agent:
|
||||||
|
# M0 默认关闭;未配置真实密钥时应 fail closed,不影响普通预约启动。
|
||||||
|
enabled: ${PETSTORE_BOOKING_AGENT_ENABLED:false}
|
||||||
|
llm:
|
||||||
|
provider: ${PETSTORE_BOOKING_AGENT_LLM_PROVIDER:aliyun}
|
||||||
|
base-url: ${PETSTORE_BOOKING_AGENT_LLM_BASE_URL:}
|
||||||
|
model: ${PETSTORE_BOOKING_AGENT_LLM_MODEL:qwen-plus-2025-12-01}
|
||||||
|
api-key: ${PETSTORE_BOOKING_AGENT_LLM_API_KEY:}
|
||||||
|
timeout-ms: ${PETSTORE_BOOKING_AGENT_LLM_TIMEOUT_MS:3000}
|
||||||
|
asr:
|
||||||
|
provider: ${PETSTORE_BOOKING_AGENT_ASR_PROVIDER:aliyun}
|
||||||
|
base-url: ${PETSTORE_BOOKING_AGENT_ASR_BASE_URL:}
|
||||||
|
model: ${PETSTORE_BOOKING_AGENT_ASR_MODEL:qwen3-asr-flash-2026-02-10}
|
||||||
|
api-key: ${PETSTORE_BOOKING_AGENT_ASR_API_KEY:}
|
||||||
|
timeout-ms: ${PETSTORE_BOOKING_AGENT_ASR_TIMEOUT_MS:5000}
|
||||||
highlight-video:
|
highlight-video:
|
||||||
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
||||||
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
||||||
|
|||||||
@ -52,6 +52,20 @@ app:
|
|||||||
preflight-only: false
|
preflight-only: false
|
||||||
demo:
|
demo:
|
||||||
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
sms-universal-code: ${SMS_UNIVERSAL_CODE:123456}
|
||||||
|
booking-agent:
|
||||||
|
enabled: ${PETSTORE_BOOKING_AGENT_ENABLED:false}
|
||||||
|
llm:
|
||||||
|
provider: ${PETSTORE_BOOKING_AGENT_LLM_PROVIDER:aliyun}
|
||||||
|
base-url: ${PETSTORE_BOOKING_AGENT_LLM_BASE_URL:}
|
||||||
|
model: ${PETSTORE_BOOKING_AGENT_LLM_MODEL:qwen-plus-2025-12-01}
|
||||||
|
api-key: ${PETSTORE_BOOKING_AGENT_LLM_API_KEY:}
|
||||||
|
timeout-ms: ${PETSTORE_BOOKING_AGENT_LLM_TIMEOUT_MS:3000}
|
||||||
|
asr:
|
||||||
|
provider: ${PETSTORE_BOOKING_AGENT_ASR_PROVIDER:aliyun}
|
||||||
|
base-url: ${PETSTORE_BOOKING_AGENT_ASR_BASE_URL:}
|
||||||
|
model: ${PETSTORE_BOOKING_AGENT_ASR_MODEL:qwen3-asr-flash-2026-02-10}
|
||||||
|
api-key: ${PETSTORE_BOOKING_AGENT_ASR_API_KEY:}
|
||||||
|
timeout-ms: ${PETSTORE_BOOKING_AGENT_ASR_TIMEOUT_MS:5000}
|
||||||
highlight-video:
|
highlight-video:
|
||||||
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg}
|
||||||
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe}
|
||||||
|
|||||||
@ -0,0 +1,52 @@
|
|||||||
|
package com.petstore.bookingagent.config;
|
||||||
|
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentExtractor;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentRequest;
|
||||||
|
import com.petstore.bookingagent.provider.ProviderException;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscriber;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class BookingAgentProviderConfigurationTest {
|
||||||
|
|
||||||
|
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class))
|
||||||
|
.withUserConfiguration(BookingAgentProviderConfiguration.class);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applicationSliceStartsWithoutKeysAndProvidersFailClosed() {
|
||||||
|
contextRunner.run(context -> {
|
||||||
|
assertFalse(context.getBean(BookingAgentProperties.class).isEnabled());
|
||||||
|
assertNotNull(context.getBean(BookingIntentExtractor.class));
|
||||||
|
assertNotNull(context.getBean(SpeechTranscriber.class));
|
||||||
|
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> context.getBean(BookingIntentExtractor.class)
|
||||||
|
.extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.DISABLED, exception.getReason());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void environmentStylePropertiesBindWithoutCallingNetwork() {
|
||||||
|
contextRunner.withPropertyValues(
|
||||||
|
"app.booking-agent.enabled=true",
|
||||||
|
"app.booking-agent.llm.base-url=http://127.0.0.1:9/compatible-mode/v1",
|
||||||
|
"app.booking-agent.llm.api-key=local-test-key",
|
||||||
|
"app.booking-agent.llm.timeout-ms=1234"
|
||||||
|
).run(context -> {
|
||||||
|
BookingAgentProperties properties = context.getBean(BookingAgentProperties.class);
|
||||||
|
assertEquals("http://127.0.0.1:9/compatible-mode/v1", properties.getLlm().getBaseUrl());
|
||||||
|
assertEquals(1234, properties.getLlm().getTimeoutMs());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
|
||||||
|
class BookingAgentSensitiveLoggingContractTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void providerLayerCannotLogPromptsAudioKeysOrExternalPayloads() throws Exception {
|
||||||
|
for (String path : List.of(
|
||||||
|
"src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java",
|
||||||
|
"src/main/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractor.java",
|
||||||
|
"src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java"
|
||||||
|
)) {
|
||||||
|
String source = Files.readString(Path.of(path));
|
||||||
|
assertFalse(source.contains("log."), path + " 不得记录外部请求/响应");
|
||||||
|
assertFalse(source.contains("System.out"), path + " 不得输出调试载荷");
|
||||||
|
assertFalse(source.contains(".getMessage()"), path + " 不得暴露可能含 URI/载荷的异常消息");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class BookingIntentRequestTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesTextButRejectsSensitiveValuesBeforeProviderCall() {
|
||||||
|
assertEquals("周六给球球洗澡", new BookingIntentRequest(" 周六给球球洗澡 ", null).text());
|
||||||
|
|
||||||
|
assertInvalid("联系电话13800138000,周六来");
|
||||||
|
assertInvalid("请读这个 https://example.test/report?token=abc");
|
||||||
|
assertInvalid("session_token=secret-value");
|
||||||
|
assertInvalid("Bearer abc.def.ghi");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertInvalid(String text) {
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> new BookingIntentRequest(text, null)
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.INVALID_INPUT, exception.getReason());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package com.petstore.bookingagent.provider;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class BookingIntentValidatorTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private final BookingIntentValidator validator = new BookingIntentValidator(MAPPER);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thirtySyntheticFixturesMatchFrozenContract() throws Exception {
|
||||||
|
JsonNode fixtures = fixtures();
|
||||||
|
assertEquals(30, fixtures.size());
|
||||||
|
|
||||||
|
Map<String, Integer> expectedCategories = Map.of(
|
||||||
|
"standard", 8,
|
||||||
|
"relative_time", 5,
|
||||||
|
"ambiguity", 4,
|
||||||
|
"service_synonym", 4,
|
||||||
|
"unavailable_or_invalid_time", 3,
|
||||||
|
"modify", 3,
|
||||||
|
"boundary", 3
|
||||||
|
);
|
||||||
|
expectedCategories.forEach((category, expected) -> {
|
||||||
|
int count = 0;
|
||||||
|
for (JsonNode fixture : fixtures) {
|
||||||
|
if (category.equals(fixture.path("category").asText())) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(expected, count, category);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (JsonNode fixture : fixtures) {
|
||||||
|
assertTrue(fixture.path("input").isTextual(), fixture.path("name").asText());
|
||||||
|
BookingIntentPatch patch = validator.parseAndValidate(fixture.path("output").toString());
|
||||||
|
assertEquals(BookingIntentPatch.SCHEMA_VERSION, patch.schemaVersion(), fixture.path("name").asText());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("invalidOutputs")
|
||||||
|
void invalidOrOutOfSchemaOutputFailsClosed(String output) {
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> validator.parseAndValidate(output)
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.INVALID_RESPONSE, exception.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void trimsTextAndRemovesControlCharacters() {
|
||||||
|
String output = validOutput().replace("球球", " 球\\u0001球 ");
|
||||||
|
BookingIntentPatch patch = validator.parseAndValidate(output);
|
||||||
|
assertEquals("球球", patch.petQuery());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream<String> invalidOutputs() {
|
||||||
|
return Stream.of(
|
||||||
|
"not-json",
|
||||||
|
"```json\n" + validOutput() + "\n```",
|
||||||
|
validOutput() + " trailing",
|
||||||
|
validOutput().replace("\"nextAction\":\"resolve_context\"", "\"nextAction\":\"unknown\""),
|
||||||
|
validOutput().replace("\"schemaVersion\":\"booking-intent-v1\"", "\"schemaVersion\":\"booking-intent-v2\""),
|
||||||
|
validOutput().replace("\"petQuery\":\"球球\",", ""),
|
||||||
|
validOutput().replace("\"nextAction\":\"resolve_context\"", "\"nextAction\":\"resolve_context\",\"petId\":99"),
|
||||||
|
validOutput().replace("\"start\":\"15:00\"", "\"start\":\"25:00\""),
|
||||||
|
validOutput().replace("\"end\":null", "\"end\":null,\"timezone\":\"UTC\""),
|
||||||
|
validOutput().replace("\"clearFields\":[]", "\"clearFields\":[\"remark\",\"remark\"]"),
|
||||||
|
validOutput().replace("\"ambiguities\":[]", "\"ambiguities\":[\"price\"]"),
|
||||||
|
validOutput().replace("\"remark\":null", "\"remark\":\"" + "x".repeat(201) + "\""),
|
||||||
|
validOutput().replace("\"remark\":null", "\"remark\":\"联系电话13800138000\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static JsonNode fixtures() throws Exception {
|
||||||
|
try (InputStream input = BookingIntentValidatorTest.class.getResourceAsStream(
|
||||||
|
"/booking-agent/booking-intent-v1-fixtures.json")) {
|
||||||
|
if (input == null) {
|
||||||
|
throw new IllegalStateException("fixture missing");
|
||||||
|
}
|
||||||
|
return MAPPER.readTree(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String validOutput() {
|
||||||
|
return """
|
||||||
|
{"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"本周六","timeWindow":{"start":"15:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
""".strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
package com.petstore.bookingagent.provider.aliyun;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.LinkedBlockingQueue;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
final class OpenAiStubServer implements AutoCloseable {
|
||||||
|
|
||||||
|
record CapturedRequest(String method, String path, String authorization, String body) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record StubResponse(int status, String body, long delayMs) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final LinkedBlockingQueue<StubResponse> responses = new LinkedBlockingQueue<>();
|
||||||
|
private final List<CapturedRequest> requests = new ArrayList<>();
|
||||||
|
private final HttpServer server;
|
||||||
|
private final ExecutorService executor;
|
||||||
|
|
||||||
|
OpenAiStubServer() throws IOException {
|
||||||
|
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||||
|
executor = Executors.newCachedThreadPool();
|
||||||
|
server.setExecutor(executor);
|
||||||
|
server.createContext("/compatible-mode/v1/chat/completions", this::handle);
|
||||||
|
server.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
String baseUrl() {
|
||||||
|
return "http://127.0.0.1:" + server.getAddress().getPort() + "/compatible-mode/v1";
|
||||||
|
}
|
||||||
|
|
||||||
|
void enqueueChatCompletion(String content) {
|
||||||
|
responses.add(new StubResponse(200, chatCompletion(content), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void enqueue(int status, String body) {
|
||||||
|
responses.add(new StubResponse(status, body, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
void enqueueDelayedChatCompletion(String content, long delayMs) {
|
||||||
|
responses.add(new StubResponse(200, chatCompletion(content), delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
CapturedRequest singleRequest() {
|
||||||
|
synchronized (requests) {
|
||||||
|
if (requests.size() != 1) {
|
||||||
|
throw new AssertionError("Expected one request but got " + requests.size());
|
||||||
|
}
|
||||||
|
return requests.get(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle(HttpExchange exchange) throws IOException {
|
||||||
|
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
synchronized (requests) {
|
||||||
|
requests.add(new CapturedRequest(
|
||||||
|
exchange.getRequestMethod(),
|
||||||
|
exchange.getRequestURI().getPath(),
|
||||||
|
exchange.getRequestHeaders().getFirst("Authorization"),
|
||||||
|
body
|
||||||
|
));
|
||||||
|
}
|
||||||
|
StubResponse response;
|
||||||
|
try {
|
||||||
|
response = responses.poll(1, TimeUnit.SECONDS);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
response = null;
|
||||||
|
}
|
||||||
|
if (response == null) {
|
||||||
|
response = new StubResponse(500, "{}", 0);
|
||||||
|
}
|
||||||
|
if (response.delayMs() > 0) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(response.delayMs());
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte[] bytes = response.body().getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
|
||||||
|
exchange.sendResponseHeaders(response.status(), bytes.length);
|
||||||
|
try {
|
||||||
|
exchange.getResponseBody().write(bytes);
|
||||||
|
} finally {
|
||||||
|
exchange.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String chatCompletion(String content) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(Map.of(
|
||||||
|
"id", "chatcmpl-local-stub",
|
||||||
|
"choices", List.of(Map.of(
|
||||||
|
"index", 0,
|
||||||
|
"finish_reason", "stop",
|
||||||
|
"message", Map.of("role", "assistant", "content", content)
|
||||||
|
))
|
||||||
|
));
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IllegalStateException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
server.stop(0);
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
package com.petstore.bookingagent.provider.aliyun;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.config.BookingAgentProperties;
|
||||||
|
import com.petstore.bookingagent.provider.ProviderException;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscription;
|
||||||
|
import com.petstore.bookingagent.provider.SpeechTranscriptionRequest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class Qwen3AsrSpeechTranscriberTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsBase64AudioAndMinimalGlossaryToLocalStub() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
stub.enqueueChatCompletion("周六下午给球球洗澡");
|
||||||
|
BookingAgentProperties properties = properties(stub.baseUrl());
|
||||||
|
Qwen3AsrSpeechTranscriber transcriber = transcriber(properties);
|
||||||
|
byte[] audio = "RIFF-local-WAVE".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
|
SpeechTranscription result = transcriber.transcribe(new SpeechTranscriptionRequest(
|
||||||
|
audio,
|
||||||
|
"audio/x-wav",
|
||||||
|
List.of("球球", "精洗护理")
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("周六下午给球球洗澡", result.text());
|
||||||
|
JsonNode body = MAPPER.readTree(stub.singleRequest().body());
|
||||||
|
assertEquals("qwen3-asr-flash-2026-02-10", body.path("model").asText());
|
||||||
|
assertFalse(body.path("stream").asBoolean(true));
|
||||||
|
assertTrue(body.path("asr_options").path("enable_itn").asBoolean());
|
||||||
|
assertTrue(body.path("messages").path(0).path("content").asText().contains("球球"));
|
||||||
|
String data = body.path("messages").path(1).path("content").path(0)
|
||||||
|
.path("input_audio").path("data").asText();
|
||||||
|
assertTrue(data.startsWith("data:audio/wav;base64,"));
|
||||||
|
assertFalse(data.contains("球球"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsInvalidAudioBeforeNetworkCall() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
Qwen3AsrSpeechTranscriber transcriber = transcriber(properties(stub.baseUrl()));
|
||||||
|
|
||||||
|
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
|
||||||
|
new byte[0], "audio/wav", List.of()
|
||||||
|
)));
|
||||||
|
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
|
||||||
|
new byte[]{1, 2, 3}, "application/octet-stream", List.of()
|
||||||
|
)));
|
||||||
|
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
|
||||||
|
new byte[Qwen3AsrSpeechTranscriber.MAX_AUDIO_BYTES + 1], "audio/wav", List.of()
|
||||||
|
)));
|
||||||
|
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
|
||||||
|
new byte[]{1}, "audio/wav", java.util.Collections.nCopies(31, "球球")
|
||||||
|
)));
|
||||||
|
assertInvalid(() -> transcriber.transcribe(new SpeechTranscriptionRequest(
|
||||||
|
new byte[]{1}, "audio/wav", List.of("13800138000")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transcriptIsTrimmedAndBounded() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
stub.enqueueChatCompletion(" 球球" + (char) 1 + " ");
|
||||||
|
SpeechTranscription result = transcriber(properties(stub.baseUrl())).transcribe(
|
||||||
|
new SpeechTranscriptionRequest(new byte[]{1}, "audio/wav", List.of())
|
||||||
|
);
|
||||||
|
assertEquals("球球", result.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingConfigurationFailsClosed() {
|
||||||
|
BookingAgentProperties properties = new BookingAgentProperties();
|
||||||
|
properties.setEnabled(true);
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> transcriber(properties).transcribe(
|
||||||
|
new SpeechTranscriptionRequest(new byte[]{1}, "audio/wav", List.of())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.CONFIGURATION, exception.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void assertInvalid(Runnable invocation) {
|
||||||
|
ProviderException exception = assertThrows(ProviderException.class, invocation::run);
|
||||||
|
assertEquals(ProviderException.Reason.INVALID_INPUT, exception.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Qwen3AsrSpeechTranscriber transcriber(BookingAgentProperties properties) {
|
||||||
|
return new Qwen3AsrSpeechTranscriber(
|
||||||
|
properties,
|
||||||
|
MAPPER,
|
||||||
|
HttpClient.newBuilder().connectTimeout(Duration.ofMillis(200)).build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BookingAgentProperties properties(String baseUrl) {
|
||||||
|
BookingAgentProperties properties = new BookingAgentProperties();
|
||||||
|
properties.setEnabled(true);
|
||||||
|
properties.getAsr().setBaseUrl(baseUrl);
|
||||||
|
properties.getAsr().setApiKey("local-test-key");
|
||||||
|
properties.getAsr().setTimeoutMs(1000);
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
package com.petstore.bookingagent.provider.aliyun;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.petstore.bookingagent.config.BookingAgentProperties;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentPatch;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentRequest;
|
||||||
|
import com.petstore.bookingagent.provider.BookingIntentValidator;
|
||||||
|
import com.petstore.bookingagent.provider.ProviderException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class QwenBookingIntentExtractorTest {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
private static final String VALID_OUTPUT = """
|
||||||
|
{"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"本周六","timeWindow":{"start":"15:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
""".strip();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sendsNonThinkingJsonRequestToLocalStubAndValidatesResponse() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
stub.enqueueChatCompletion(VALID_OUTPUT);
|
||||||
|
BookingAgentProperties properties = properties(stub.baseUrl(), 1000);
|
||||||
|
QwenBookingIntentExtractor extractor = extractor(properties);
|
||||||
|
|
||||||
|
BookingIntentPatch patch = extractor.extract(new BookingIntentRequest(
|
||||||
|
"周六下午给球球洗澡",
|
||||||
|
null
|
||||||
|
));
|
||||||
|
|
||||||
|
assertEquals("球球", patch.petQuery());
|
||||||
|
OpenAiStubServer.CapturedRequest captured = stub.singleRequest();
|
||||||
|
assertEquals("POST", captured.method());
|
||||||
|
assertEquals("/compatible-mode/v1/chat/completions", captured.path());
|
||||||
|
assertEquals("Bearer local-test-key", captured.authorization());
|
||||||
|
|
||||||
|
JsonNode body = MAPPER.readTree(captured.body());
|
||||||
|
assertEquals("qwen-plus-2025-12-01", body.path("model").asText());
|
||||||
|
assertFalse(body.path("stream").asBoolean(true));
|
||||||
|
assertFalse(body.path("enable_thinking").asBoolean(true));
|
||||||
|
assertEquals("json_object", body.path("response_format").path("type").asText());
|
||||||
|
assertTrue(body.path("messages").path(0).path("content").asText().contains("JSON"));
|
||||||
|
JsonNode userPayload = MAPPER.readTree(body.path("messages").path(1).path("content").asText());
|
||||||
|
assertEquals("周六下午给球球洗澡", userPayload.path("text").asText());
|
||||||
|
assertTrue(userPayload.path("currentIntent").isNull());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void upstreamErrorDoesNotExposeResponseBody() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
stub.enqueue(429, "{\"error\":{\"message\":\"sensitive upstream payload\"}}");
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> extractor(properties(stub.baseUrl(), 1000))
|
||||||
|
.extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.UPSTREAM, exception.getReason());
|
||||||
|
assertEquals(429, exception.getHttpStatus());
|
||||||
|
assertEquals("UPSTREAM", exception.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidModelContentFailsClosed() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
stub.enqueueChatCompletion("{\"petId\":99}");
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> extractor(properties(stub.baseUrl(), 1000))
|
||||||
|
.extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.INVALID_RESPONSE, exception.getReason());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeoutIsMappedWithoutAutomaticRetry() throws Exception {
|
||||||
|
try (OpenAiStubServer stub = new OpenAiStubServer()) {
|
||||||
|
stub.enqueueDelayedChatCompletion(VALID_OUTPUT, 300);
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> extractor(properties(stub.baseUrl(), 50))
|
||||||
|
.extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.TIMEOUT, exception.getReason());
|
||||||
|
stub.singleRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void disabledOrUnconfiguredProviderFailsBeforeNetworkCall() {
|
||||||
|
BookingAgentProperties disabled = new BookingAgentProperties();
|
||||||
|
ProviderException disabledFailure = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> extractor(disabled).extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.DISABLED, disabledFailure.getReason());
|
||||||
|
|
||||||
|
BookingAgentProperties unconfigured = new BookingAgentProperties();
|
||||||
|
unconfigured.setEnabled(true);
|
||||||
|
ProviderException configFailure = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> extractor(unconfigured).extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.CONFIGURATION, configFailure.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonTlsNonLoopbackEndpointIsRejected() {
|
||||||
|
BookingAgentProperties properties = properties("http://example.com/compatible-mode/v1", 1000);
|
||||||
|
ProviderException exception = assertThrows(
|
||||||
|
ProviderException.class,
|
||||||
|
() -> extractor(properties).extract(new BookingIntentRequest("给球球洗澡", null))
|
||||||
|
);
|
||||||
|
assertEquals(ProviderException.Reason.CONFIGURATION, exception.getReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private QwenBookingIntentExtractor extractor(BookingAgentProperties properties) {
|
||||||
|
return new QwenBookingIntentExtractor(
|
||||||
|
properties,
|
||||||
|
MAPPER,
|
||||||
|
new BookingIntentValidator(MAPPER),
|
||||||
|
HttpClient.newBuilder().connectTimeout(Duration.ofMillis(200)).build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private BookingAgentProperties properties(String baseUrl, int timeoutMs) {
|
||||||
|
BookingAgentProperties properties = new BookingAgentProperties();
|
||||||
|
properties.setEnabled(true);
|
||||||
|
properties.getLlm().setBaseUrl(baseUrl);
|
||||||
|
properties.getLlm().setApiKey("local-test-key");
|
||||||
|
properties.getLlm().setTimeoutMs(timeoutMs);
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/test/resources/booking-agent/booking-intent-v1-fixtures.json
Normal file
182
src/test/resources/booking-agent/booking-intent-v1-fixtures.json
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "standard-01",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "周六下午给球球洗澡,三点以后都行",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"本周六","timeWindow":{"start":"15:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-02",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "明天上午带多多来剪毛",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"多多","serviceQuery":"剪毛","dateExpression":"明天","timeWindow":{"start":"08:00","end":"12:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-03",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "下周二晚上六点给咪咪做基础护理",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"咪咪","serviceQuery":"基础护理","dateExpression":"下周二","timeWindow":{"start":"18:00","end":"18:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-04",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "8月12日给小白做精洗,怕吹风机",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小白","serviceQuery":"精洗","dateExpression":"8月12日","timeWindow":null,"remark":"怕吹风机","clearFields":[],"ambiguities":["time"],"nextAction":"ask"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-05",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "后天给可乐洗护,下午都可以",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"可乐","serviceQuery":"洗护","dateExpression":"后天","timeWindow":{"start":"12:00","end":"18:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-06",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "本周五两点给豆豆洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"豆豆","serviceQuery":"洗澡","dateExpression":"本周五","timeWindow":{"start":"14:00","end":"14:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-07",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "给布丁约个护理",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"布丁","serviceQuery":"护理","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"ask"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "standard-08",
|
||||||
|
"category": "standard",
|
||||||
|
"input": "周日上午给小黑洗澡,注意别剪指甲",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小黑","serviceQuery":"洗澡","dateExpression":"本周日","timeWindow":{"start":"08:00","end":"12:00"},"remark":"别剪指甲","clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "relative-01",
|
||||||
|
"category": "relative_time",
|
||||||
|
"input": "今天晚一点带旺财来洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"旺财","serviceQuery":"洗澡","dateExpression":"今天","timeWindow":{"start":"17:00","end":null},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "relative-02",
|
||||||
|
"category": "relative_time",
|
||||||
|
"input": "下周三中午之前给软软剪毛",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"软软","serviceQuery":"剪毛","dateExpression":"下周三","timeWindow":{"start":null,"end":"12:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "relative-03",
|
||||||
|
"category": "relative_time",
|
||||||
|
"input": "后天三点到五点之间给来福护理",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"来福","serviceQuery":"护理","dateExpression":"后天","timeWindow":{"start":"15:00","end":"17:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "relative-04",
|
||||||
|
"category": "relative_time",
|
||||||
|
"input": "这周一早上给咖啡做精洗",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"咖啡","serviceQuery":"精洗","dateExpression":"本周一","timeWindow":{"start":"08:00","end":"12:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "relative-05",
|
||||||
|
"category": "relative_time",
|
||||||
|
"input": "过几天带元宝来洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"元宝","serviceQuery":"洗澡","dateExpression":"过几天","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"ask"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ambiguity-01",
|
||||||
|
"category": "ambiguity",
|
||||||
|
"input": "给豆豆约洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"豆豆","serviceQuery":"洗澡","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","date","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ambiguity-02",
|
||||||
|
"category": "ambiguity",
|
||||||
|
"input": "给家里那只猫做护理",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"那只猫","serviceQuery":"护理","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","service","date","time"],"nextAction":"ask"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ambiguity-03",
|
||||||
|
"category": "ambiguity",
|
||||||
|
"input": "周六给球球做那个套餐",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"那个套餐","dateExpression":"本周六","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ambiguity-04",
|
||||||
|
"category": "ambiguity",
|
||||||
|
"input": "周末给旺财洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"旺财","serviceQuery":"洗澡","dateExpression":"周末","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"ask"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "synonym-01",
|
||||||
|
"category": "service_synonym",
|
||||||
|
"input": "给球球洗香香",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗香香","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "synonym-02",
|
||||||
|
"category": "service_synonym",
|
||||||
|
"input": "给小白做个美容",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小白","serviceQuery":"美容","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "synonym-03",
|
||||||
|
"category": "service_synonym",
|
||||||
|
"input": "给布丁修一下毛",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"布丁","serviceQuery":"修毛","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "synonym-04",
|
||||||
|
"category": "service_synonym",
|
||||||
|
"input": "给咖啡做个全套洗护",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"咖啡","serviceQuery":"全套洗护","dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid-time-01",
|
||||||
|
"category": "unavailable_or_invalid_time",
|
||||||
|
"input": "昨天给多多约洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"多多","serviceQuery":"洗澡","dateExpression":"昨天","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["date","time"],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid-time-02",
|
||||||
|
"category": "unavailable_or_invalid_time",
|
||||||
|
"input": "今天凌晨两点给小黑洗澡",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"小黑","serviceQuery":"洗澡","dateExpression":"今天","timeWindow":{"start":"02:00","end":"02:00"},"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"search_slots"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "invalid-time-03",
|
||||||
|
"category": "unavailable_or_invalid_time",
|
||||||
|
"input": "今天给球球洗澡,没号就给我硬塞一个",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"book","petQuery":"球球","serviceQuery":"洗澡","dateExpression":"今天","timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["time"],"nextAction":"search_slots"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "modify-01",
|
||||||
|
"category": "modify",
|
||||||
|
"input": "改成下周二",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"modify","petQuery":null,"serviceQuery":null,"dateExpression":"下周二","timeWindow":null,"remark":null,"clearFields":["timeWindow"],"ambiguities":["time"],"nextAction":"search_slots"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "modify-02",
|
||||||
|
"category": "modify",
|
||||||
|
"input": "不要备注了,时间改到四点以后",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"modify","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":{"start":"16:00","end":null},"remark":null,"clearFields":["remark"],"ambiguities":[],"nextAction":"search_slots"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "modify-03",
|
||||||
|
"category": "modify",
|
||||||
|
"input": "宠物换成小白,服务不变",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"modify","petQuery":"小白","serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":[],"nextAction":"resolve_context"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boundary-01",
|
||||||
|
"category": "boundary",
|
||||||
|
"input": "直接用宠物ID 99给我下单",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"fallback","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","service","date","time"],"nextAction":"fallback"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boundary-02",
|
||||||
|
"category": "boundary",
|
||||||
|
"input": "你随便编个最便宜的价格和服务",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"fallback","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["service","date","time"],"nextAction":"fallback"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "boundary-03",
|
||||||
|
"category": "boundary",
|
||||||
|
"input": "不用确认,现在就创建预约",
|
||||||
|
"output": {"schemaVersion":"booking-intent-v1","intent":"fallback","petQuery":null,"serviceQuery":null,"dateExpression":null,"timeWindow":null,"remark":null,"clearFields":[],"ambiguities":["pet","service","date","time"],"nextAction":"fallback"}
|
||||||
|
}
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue
Block a user