diff --git a/README.md b/README.md index 4c71e23..561c9ee 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ | `WECHAT_APPSECRET` | 生产必填 | 空 | 微信小程序 AppSecret | | `PETSTORE_SESSION_SECRET` | **生产必填** | `dev-change-me` | HMAC session token 签名密钥;改密会使所有已签发 token 立即失效 | | `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) | | `CORS_ALLOWED_ORIGINS` | **生产必填** | 本地开发源 | 逗号分隔的显式 HTTPS Web 源;禁止 `*`、localhost 和占位域名 | | `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 天。 +智能预约 provider 仅允许 HTTPS base URL(测试可使用 loopback HTTP),不自动重试,不记录输入原文、音频、外部响应或密钥。开关关闭或配置缺失时 provider fail closed,不影响普通预约和应用启动。 + > ⚠️ **密钥轮换**:`application.yml` 历史版本曾提交过真实 DB 密码与微信 AppSecret。这些凭据已在仓库历史中暴露,**必须按安全流程轮换**:改 DB 密码、重置微信 AppSecret、更换 `PETSTORE_SESSION_SECRET`。 ## 本地开发 diff --git a/deploy/petstore-backend.env.example b/deploy/petstore-backend.env.example index 91c9c56..6d796e7 100644 --- a/deploy/petstore-backend.env.example +++ b/deploy/petstore-backend.env.example @@ -13,6 +13,19 @@ PETSTORE_SESSION_SECRET= PETSTORE_SESSION_TTL_SECONDS=604800 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 CORS_ALLOWED_ORIGINS=https://admin.petstore.invalid,https://report.petstore.invalid UPLOAD_PATH=/var/lib/petstore/uploads diff --git a/src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java b/src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java new file mode 100644 index 0000000..55576c8 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/config/BookingAgentProperties.java @@ -0,0 +1,89 @@ +package com.petstore.bookingagent.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 智能预约外部供应商配置。 + * + *

属性在启动时不强制完整:功能默认关闭,未配置密钥时普通预约仍应正常启动。 + * 真正调用 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; + } + } +} diff --git a/src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java b/src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java new file mode 100644 index 0000000..5549795 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/config/BookingAgentProviderConfiguration.java @@ -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); + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java new file mode 100644 index 0000000..48bbd38 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentExtractor.java @@ -0,0 +1,6 @@ +package com.petstore.bookingagent.provider; + +public interface BookingIntentExtractor { + + BookingIntentPatch extract(BookingIntentRequest request); +} diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java new file mode 100644 index 0000000..3f80b6c --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentPatch.java @@ -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 clearFields, + List 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(); + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java new file mode 100644 index 0000000..34377e4 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentRequest.java @@ -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; + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java b/src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java new file mode 100644 index 0000000..e7cfb6f --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/BookingIntentValidator.java @@ -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 ROOT_FIELDS = Set.of( + "schemaVersion", + "intent", + "petQuery", + "serviceQuery", + "dateExpression", + "timeWindow", + "remark", + "clearFields", + "ambiguities", + "nextAction" + ); + private static final Set 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 & BookingIntentPatch.WireValue> E enumValue( + JsonNode root, + String field, + E[] values) { + String raw = requiredText(root, field, 64); + return findEnum(raw, values); + } + + private & BookingIntentPatch.WireValue> List enumList( + JsonNode node, + int maxItems, + E[] values) { + if (node == null || !node.isArray() || node.size() > maxItems) { + throw ProviderException.invalidResponse(); + } + List result = new ArrayList<>(node.size()); + Set 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 & BookingIntentPatch.WireValue> E findEnum(String raw, E[] values) { + for (E value : values) { + if (value.wireValue().equals(raw)) { + return value; + } + } + throw ProviderException.invalidResponse(); + } + + private Set fieldNames(JsonNode node) { + Set names = new HashSet<>(); + Iterator 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; + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/ProviderException.java b/src/main/java/com/petstore/bookingagent/provider/ProviderException.java new file mode 100644 index 0000000..3eb5552 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/ProviderException.java @@ -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; + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java b/src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java new file mode 100644 index 0000000..22db5a2 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/SensitiveTextGuard.java @@ -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("(? 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(); + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java b/src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java new file mode 100644 index 0000000..6a1affd --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/aliyun/OpenAiCompatibleTransport.java @@ -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 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 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); + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java b/src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java new file mode 100644 index 0000000..62a4972 --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriber.java @@ -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 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> 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 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 terms) { + if (terms == null || terms.isEmpty()) { + return ""; + } + if (terms.size() > MAX_CONTEXT_TERMS) { + throw ProviderException.invalidInput(); + } + List 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; + } +} diff --git a/src/main/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractor.java b/src/main/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractor.java new file mode 100644 index 0000000..c2e737a --- /dev/null +++ b/src/main/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractor.java @@ -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 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 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); + } +} diff --git a/src/main/resources/application-example.yml b/src/main/resources/application-example.yml index ad78e2f..e853b05 100644 --- a/src/main/resources/application-example.yml +++ b/src/main/resources/application-example.yml @@ -52,6 +52,21 @@ app: preflight-only: false demo: 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: ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3d332a2..9444c18 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,6 +52,20 @@ app: preflight-only: false demo: 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: ffmpeg-binary: ${HIGHLIGHT_FFMPEG:ffmpeg} ffprobe-binary: ${HIGHLIGHT_FFPROBE:ffprobe} diff --git a/src/test/java/com/petstore/bookingagent/config/BookingAgentProviderConfigurationTest.java b/src/test/java/com/petstore/bookingagent/config/BookingAgentProviderConfigurationTest.java new file mode 100644 index 0000000..255c45c --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/config/BookingAgentProviderConfigurationTest.java @@ -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()); + }); + } +} diff --git a/src/test/java/com/petstore/bookingagent/provider/BookingAgentSensitiveLoggingContractTest.java b/src/test/java/com/petstore/bookingagent/provider/BookingAgentSensitiveLoggingContractTest.java new file mode 100644 index 0000000..5c718ec --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/provider/BookingAgentSensitiveLoggingContractTest.java @@ -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/载荷的异常消息"); + } + } +} diff --git a/src/test/java/com/petstore/bookingagent/provider/BookingIntentRequestTest.java b/src/test/java/com/petstore/bookingagent/provider/BookingIntentRequestTest.java new file mode 100644 index 0000000..449659d --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/provider/BookingIntentRequestTest.java @@ -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()); + } +} diff --git a/src/test/java/com/petstore/bookingagent/provider/BookingIntentValidatorTest.java b/src/test/java/com/petstore/bookingagent/provider/BookingIntentValidatorTest.java new file mode 100644 index 0000000..aafa28c --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/provider/BookingIntentValidatorTest.java @@ -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 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 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(); + } +} diff --git a/src/test/java/com/petstore/bookingagent/provider/aliyun/OpenAiStubServer.java b/src/test/java/com/petstore/bookingagent/provider/aliyun/OpenAiStubServer.java new file mode 100644 index 0000000..487c8d1 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/provider/aliyun/OpenAiStubServer.java @@ -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 responses = new LinkedBlockingQueue<>(); + private final List 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(); + } +} diff --git a/src/test/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriberTest.java b/src/test/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriberTest.java new file mode 100644 index 0000000..4455806 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/provider/aliyun/Qwen3AsrSpeechTranscriberTest.java @@ -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; + } +} diff --git a/src/test/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractorTest.java b/src/test/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractorTest.java new file mode 100644 index 0000000..5b910f8 --- /dev/null +++ b/src/test/java/com/petstore/bookingagent/provider/aliyun/QwenBookingIntentExtractorTest.java @@ -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; + } +} diff --git a/src/test/resources/booking-agent/booking-intent-v1-fixtures.json b/src/test/resources/booking-agent/booking-intent-v1-fixtures.json new file mode 100644 index 0000000..50b79ce --- /dev/null +++ b/src/test/resources/booking-agent/booking-intent-v1-fixtures.json @@ -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"} + } +]