From 4dbf5dd178b73dc7be27ecaea1189230b570dcc2 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Sat, 10 Oct 2026 13:29:36 +0800 Subject: [PATCH 1/5] feat(decision-model): Add decision model API support - Implement DecisionModel class to provide decision model invocation functionality - Define DecisionModelParam class to handle input parameter validation - Create DecisionAnswer and DecisionQuestion data structures - Implement DecisionModelResult class to parse server responses - Add DecisionModelUsage statistics class to record token usage - Add decision model usage examples and documentation to README - Write comprehensive test cases covering HTTP request construction and response parsing --- README.md | 60 ++++ README_zh.md | 62 +++- samples/DecisionModelUsage.java | 47 +++ .../decision_model/DecisionAnswer.java | 30 ++ .../decision_model/DecisionModel.java | 109 +++++++ .../decision_model/DecisionModelParam.java | 83 +++++ .../decision_model/DecisionModelResult.java | 31 ++ .../decision_model/DecisionModelUsage.java | 12 + .../decision_model/DecisionQuestion.java | 70 +++++ .../alibaba/dashscope/TestDecisionModel.java | 285 ++++++++++++++++++ 10 files changed, 788 insertions(+), 1 deletion(-) create mode 100644 samples/DecisionModelUsage.java create mode 100644 src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java create mode 100644 src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java create mode 100644 src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java create mode 100644 src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java create mode 100644 src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java create mode 100644 src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java create mode 100644 src/test/java/com/alibaba/dashscope/TestDecisionModel.java diff --git a/README.md b/README.md index bee11a6..0752b64 100644 --- a/README.md +++ b/README.md @@ -173,3 +173,63 @@ public class Main { ``` The `call` method accepts a `GenerationParam`, and returns a `GenerationResult`, you can also catch the exception with a try-catch block. + +### Decision Model + +The decision model answers a batch of multiple-choice (choice), yes/no (noul) and ordered-scale (score) questions against a given business state, see the [Decision Model API document](https://platform.qianwenai.com/docs/api-reference/decision-model-api). + +```java +import com.alibaba.dashscope.decision_model.DecisionModel; +import com.alibaba.dashscope.decision_model.DecisionModelParam; +import com.alibaba.dashscope.decision_model.DecisionModelResult; +import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.utils.JsonUtils; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class Main { + + public static void main(String[] args) { + // The business state. + Map state = new HashMap<>(); + state.put("ticket_id", "T-1001"); + state.put("content", "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务,要求立即处理。"); + + // The questions. + Map options = new LinkedHashMap<>(); + options.put("billing", "支付、退款和账单问题"); + options.put("technical", "产品故障和集成问题"); + List levels = Arrays.asList( + "轻微问题,不影响功能", + "部分功能受影响,但存在替代方案", + "核心功能不可用,没有替代方案", + "造成严重业务或安全影响"); + Map questions = new LinkedHashMap<>(); + questions.put("department", DecisionQuestion.choice("应该由哪个团队处理?", options)); + questions.put("escalate", DecisionQuestion.noul("是否需要立即通知值班人员?")); + questions.put("severity", DecisionQuestion.score("这个问题有多严重?", levels)); + + DecisionModelParam param = DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(state) + .questions(questions) + .build(); + + try { + DecisionModel decisionModel = new DecisionModel(); + DecisionModelResult result = decisionModel.call(param); + System.out.println(JsonUtils.toJson(result)); + } catch (ApiException | NoApiKeyException | InputRequiredException e) { + System.err.println("An error occurred: " + e.getMessage()); + } + } +} +``` + +The `call` method accepts a `DecisionModelParam`, and returns a `DecisionModelResult` whose `answers` are keyed by question id. Streaming is not supported. The API key is read from the `DASHSCOPE_API_KEY` environment variable by default, or set explicitly with `apiKey(...)`; the request url defaults to `https://maas.qianwenaiapi.com/compatible-mode/v1/systemone`, override it with `new DecisionModel(baseUrl)` or the `DASHSCOPE_HTTP_BASE_URL` environment variable. diff --git a/README_zh.md b/README_zh.md index 8d67e21..b5c9c47 100644 --- a/README_zh.md +++ b/README_zh.md @@ -172,4 +172,64 @@ public class Main { } ``` -`call` 方法接受 `GenerationParam`,并返回 `GenerationResult`,您也可以通过 try-catch 块捕获异常。 \ No newline at end of file +`call` 方法接受 `GenerationParam`,并返回 `GenerationResult`,您也可以通过 try-catch 块捕获异常。 + +### 决策模型 + +决策模型(Decision Model)针对给定的业务状态,批量回答多选一(choice)、是否判断(noul)、有序量表(score)三类问题,详见[决策模型 API 文档](https://platform.qianwenai.com/docs/api-reference/decision-model-api)。 + +```java +import com.alibaba.dashscope.decision_model.DecisionModel; +import com.alibaba.dashscope.decision_model.DecisionModelParam; +import com.alibaba.dashscope.decision_model.DecisionModelResult; +import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.utils.JsonUtils; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class Main { + + public static void main(String[] args) { + // 业务状态 + Map state = new HashMap<>(); + state.put("ticket_id", "T-1001"); + state.put("content", "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务,要求立即处理。"); + + // 问题列表 + Map options = new LinkedHashMap<>(); + options.put("billing", "支付、退款和账单问题"); + options.put("technical", "产品故障和集成问题"); + List levels = Arrays.asList( + "轻微问题,不影响功能", + "部分功能受影响,但存在替代方案", + "核心功能不可用,没有替代方案", + "造成严重业务或安全影响"); + Map questions = new LinkedHashMap<>(); + questions.put("department", DecisionQuestion.choice("应该由哪个团队处理?", options)); + questions.put("escalate", DecisionQuestion.noul("是否需要立即通知值班人员?")); + questions.put("severity", DecisionQuestion.score("这个问题有多严重?", levels)); + + DecisionModelParam param = DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(state) + .questions(questions) + .build(); + + try { + DecisionModel decisionModel = new DecisionModel(); + DecisionModelResult result = decisionModel.call(param); + System.out.println(JsonUtils.toJson(result)); + } catch (ApiException | NoApiKeyException | InputRequiredException e) { + System.err.println("An error occurred: " + e.getMessage()); + } + } +} +``` + +`call` 方法接受 `DecisionModelParam`,并返回 `DecisionModelResult`,其中 `answers` 按问题 id 给出答案,不支持流式输出。API Key 默认从环境变量 `DASHSCOPE_API_KEY` 获取,也可以通过 `apiKey(...)` 指定;请求地址默认为 `https://maas.qianwenaiapi.com/compatible-mode/v1/systemone`,可通过 `new DecisionModel(baseUrl)` 或环境变量 `DASHSCOPE_HTTP_BASE_URL` 覆盖。 \ No newline at end of file diff --git a/samples/DecisionModelUsage.java b/samples/DecisionModelUsage.java new file mode 100644 index 0000000..baa4237 --- /dev/null +++ b/samples/DecisionModelUsage.java @@ -0,0 +1,47 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. + +import com.alibaba.dashscope.decision_model.DecisionModel; +import com.alibaba.dashscope.decision_model.DecisionModelParam; +import com.alibaba.dashscope.decision_model.DecisionModelResult; +import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.utils.JsonUtils; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class DecisionModelUsage { + public static void main(String[] args) throws Exception { + Map state = new HashMap<>(); + state.put("ticket_id", "T-1001"); + state.put("content", "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务,要求立即处理。"); + + Map options = new LinkedHashMap<>(); + options.put("billing", "支付、退款和账单问题"); + options.put("technical", "产品故障和集成问题"); + + List levels = + Arrays.asList( + "轻微问题,不影响功能", + "部分功能受影响,但存在替代方案", + "核心功能不可用,没有替代方案", + "造成严重业务或安全影响"); + + Map questions = new LinkedHashMap<>(); + questions.put("department", DecisionQuestion.choice("应该由哪个团队处理?", options)); + questions.put("escalate", DecisionQuestion.noul("是否需要立即通知值班人员?")); + questions.put("severity", DecisionQuestion.score("这个问题有多严重?", levels)); + + DecisionModelParam param = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(state) + .questions(questions) + .build(); + + DecisionModel decisionModel = new DecisionModel(); + DecisionModelResult result = decisionModel.call(param); + System.out.println(JsonUtils.toJson(result)); + } +} diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java b/src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java new file mode 100644 index 0000000..312e0d5 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java @@ -0,0 +1,30 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision_model; + +import java.util.Map; +import lombok.Data; + +/** The answer of a single decision question. */ +@Data +public class DecisionAnswer { + /** The question type, one of choice/noul/score. */ + private String type; + + /** The selected option key, only for choice questions. */ + private String choice; + + /** The probability of "yes", only for noul questions. */ + private Double noul; + + /** The expected score over the scale levels, only for score questions. */ + private Double score; + + /** The confidence of the answer, for choice and score questions. */ + private Double confidence; + + /** The probability of each option or scale level. */ + private Map probabilities; + + /** The description of each scale level, only for score questions. */ + private Map legend; +} diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java new file mode 100644 index 0000000..fa45b1a --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java @@ -0,0 +1,109 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision_model; + +import com.alibaba.dashscope.api.GeneralApi; +import com.alibaba.dashscope.base.HalfDuplexParamBase; +import com.alibaba.dashscope.common.DashScopeResult; +import com.alibaba.dashscope.common.ResultCallback; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.exception.NoApiKeyException; +import com.alibaba.dashscope.protocol.ConnectionOptions; +import com.alibaba.dashscope.protocol.GeneralServiceOption; +import com.alibaba.dashscope.protocol.HttpMethod; +import com.alibaba.dashscope.protocol.Protocol; +import com.alibaba.dashscope.protocol.StreamingMode; +import com.alibaba.dashscope.utils.Constants; + +/** + * The decision model api, answers a batch of choice/noul/score questions against a business state. + * + *

Document: https://platform.qianwenai.com/docs/api-reference/decision-model-api + */ +public final class DecisionModel { + private static final String DEFAULT_BASE_URL = "https://maas.qianwenaiapi.com/compatible-mode/v1"; + + private final GeneralApi api; + private final GeneralServiceOption serviceOption; + + private GeneralServiceOption defaultServiceOption() { + return GeneralServiceOption.builder() + .protocol(Protocol.HTTP) + .httpMethod(HttpMethod.POST) + .streamingMode(StreamingMode.NONE) + .path("systemone") + .module("decision_model") + .baseHttpUrl( + System.getenv().getOrDefault(Constants.DASHSCOPE_HTTP_BASE_URL_ENV, DEFAULT_BASE_URL)) + .build(); + } + + public DecisionModel() { + serviceOption = defaultServiceOption(); + api = new GeneralApi<>(); + } + + public DecisionModel(String baseUrl) { + serviceOption = defaultServiceOption(); + serviceOption.setBaseHttpUrl(baseUrl); + api = new GeneralApi<>(); + } + + public DecisionModel(String baseUrl, ConnectionOptions connectionOptions) { + serviceOption = defaultServiceOption(); + serviceOption.setBaseHttpUrl(baseUrl); + api = new GeneralApi<>(connectionOptions); + } + + /** + * Call the server to get the whole result. + * + * @param param The input param of class `DecisionModelParam`. + * @return The output structure of `DecisionModelResult`. + * @throws NoApiKeyException Can not find api key + * @throws ApiException The request failed, possibly due to a network or data error. + * @throws InputRequiredException Missing required input fields. + */ + public DecisionModelResult call(DecisionModelParam param) + throws ApiException, NoApiKeyException, InputRequiredException { + param.validate(); + serviceOption.setIsSSE(false); + serviceOption.setStreamingMode(StreamingMode.NONE); + return DecisionModelResult.fromDashScopeResult(api.call(param, serviceOption)); + } + + /** + * Call the server to get the result in the callback function. + * + * @param param The input param of class `DecisionModelParam`. + * @param callback The callback to receive the result of `DecisionModelResult`. + * @throws NoApiKeyException Can not find api key + * @throws ApiException The request failed, possibly due to a network or data error. + * @throws InputRequiredException Missing required input fields. + */ + public void call(DecisionModelParam param, ResultCallback callback) + throws ApiException, NoApiKeyException, InputRequiredException { + param.validate(); + serviceOption.setIsSSE(false); + serviceOption.setStreamingMode(StreamingMode.NONE); + api.call( + param, + serviceOption, + new ResultCallback() { + @Override + public void onEvent(DashScopeResult message) { + callback.onEvent(DecisionModelResult.fromDashScopeResult(message)); + } + + @Override + public void onComplete() { + callback.onComplete(); + } + + @Override + public void onError(Exception e) { + callback.onError(e); + } + }); + } +} diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java new file mode 100644 index 0000000..e3bd99b --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java @@ -0,0 +1,83 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision_model; + +import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import io.reactivex.annotations.NonNull; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** The input param of the decision model api. */ +@EqualsAndHashCode(callSuper = true) +@Data +@SuperBuilder +public class DecisionModelParam extends FlattenHalfDuplexParamBase { + + /** Available decision models. */ + public static class Models { + public static final String DECISION_MODEL_PREVIEW = "decision-model-preview"; + } + + /** The model name, currently only {@link Models#DECISION_MODEL_PREVIEW}. */ + @NonNull private String model; + + /** The business state, an arbitrary json object, e.g. {"ticket_id": "T-1001", ...}. */ + @NonNull private Map state; + + /** The questions to answer, key is the question id. */ + @NonNull private Map questions; + + @Override + public JsonObject getHttpBody() { + JsonObject requestObject = new JsonObject(); + requestObject.addProperty("model", model); + requestObject.add("state", JsonUtils.toJsonObject(state)); + JsonObject questionsObject = new JsonObject(); + for (Map.Entry entry : questions.entrySet()) { + questionsObject.add(entry.getKey(), entry.getValue().toJson()); + } + requestObject.add("questions", questionsObject); + addExtraBody(requestObject); + return requestObject; + } + + @Override + public void validate() throws InputRequiredException { + if (model == null || model.isEmpty()) { + throw new InputRequiredException("The model must be set"); + } + if (state == null) { + throw new InputRequiredException("The state must be set"); + } + if (questions == null || questions.isEmpty()) { + throw new InputRequiredException("The questions must not be null or empty"); + } + for (Map.Entry entry : questions.entrySet()) { + DecisionQuestion question = entry.getValue(); + if (question == null) { + throw new InputRequiredException( + String.format("The question [%s] must not be null", entry.getKey())); + } + String type = question.getType(); + if (!DecisionQuestion.Types.CHOICE.equals(type) + && !DecisionQuestion.Types.NOUL.equals(type) + && !DecisionQuestion.Types.SCORE.equals(type)) { + throw new InputRequiredException( + String.format( + "The type of question [%s] must be one of choice/noul/score", entry.getKey())); + } + if (question.getInstructions() == null || question.getInstructions().isEmpty()) { + throw new InputRequiredException( + String.format("The instructions of question [%s] must be set", entry.getKey())); + } + if (!DecisionQuestion.Types.NOUL.equals(type) && question.getCriteria() == null) { + throw new InputRequiredException( + String.format("The criteria of question [%s] must be set", entry.getKey())); + } + } + } +} diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java new file mode 100644 index 0000000..7823f73 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java @@ -0,0 +1,31 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision_model; + +import com.alibaba.dashscope.common.DashScopeResult; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import com.google.gson.annotations.SerializedName; +import java.util.Map; +import lombok.Data; + +/** The output result of the decision model api. */ +@Data +public class DecisionModelResult { + private String model; + + @SerializedName("request_id") + private String requestId; + + /** The answers, key is the question id. */ + private Map answers; + + private DecisionModelUsage usage; + + /** The server side latency in milliseconds. */ + @SerializedName("latency_ms") + private Double latencyMs; + + public static DecisionModelResult fromDashScopeResult(DashScopeResult result) { + return JsonUtils.fromJsonObject((JsonObject) result.getOutput(), DecisionModelResult.class); + } +} diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java new file mode 100644 index 0000000..622f74d --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java @@ -0,0 +1,12 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision_model; + +import com.google.gson.annotations.SerializedName; +import lombok.Data; + +/** The token usage of the decision model api. */ +@Data +public class DecisionModelUsage { + @SerializedName("input_tokens") + private Integer inputTokens; +} diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java b/src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java new file mode 100644 index 0000000..9b4bdcb --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java @@ -0,0 +1,70 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision_model; + +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.experimental.SuperBuilder; + +/** + * A single question for the decision model. Use the static factory methods {@link #choice}, {@link + * #noul} and {@link #score} to create instances. + */ +@Data +@SuperBuilder +public class DecisionQuestion { + /** Question types. */ + public static class Types { + /** Multiple choice, criteria is a map of option key to option description. */ + public static final String CHOICE = "choice"; + /** Yes/no judgement, no criteria needed. */ + public static final String NOUL = "noul"; + /** Ordered scale, criteria is a list of level descriptions. */ + public static final String SCORE = "score"; + } + + /** The question type, one of {@link Types}. */ + private String type; + + /** The question description. */ + private String instructions; + + /** + * The question criteria. For {@link Types#CHOICE} it is a {@code Map} of option + * key to option description. For {@link Types#SCORE} it is a {@code List} of level + * descriptions. Not used for {@link Types#NOUL}. + */ + private Object criteria; + + public static DecisionQuestion choice(String instructions, Map options) { + return DecisionQuestion.builder() + .type(Types.CHOICE) + .instructions(instructions) + .criteria(options) + .build(); + } + + public static DecisionQuestion noul(String instructions) { + return DecisionQuestion.builder().type(Types.NOUL).instructions(instructions).build(); + } + + public static DecisionQuestion score(String instructions, List levels) { + return DecisionQuestion.builder() + .type(Types.SCORE) + .instructions(instructions) + .criteria(levels) + .build(); + } + + JsonObject toJson() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("type", type); + jsonObject.addProperty("instructions", instructions); + if (criteria != null) { + jsonObject.add("criteria", JsonUtils.toJsonElement(criteria)); + } + return jsonObject; + } +} diff --git a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java new file mode 100644 index 0000000..70a5434 --- /dev/null +++ b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java @@ -0,0 +1,285 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. + +package com.alibaba.dashscope; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.alibaba.dashscope.common.DashScopeResult; +import com.alibaba.dashscope.decision_model.DecisionAnswer; +import com.alibaba.dashscope.decision_model.DecisionModel; +import com.alibaba.dashscope.decision_model.DecisionModelParam; +import com.alibaba.dashscope.decision_model.DecisionModelResult; +import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.exception.ApiException; +import com.alibaba.dashscope.exception.InputRequiredException; +import com.alibaba.dashscope.protocol.NetworkResponse; +import com.alibaba.dashscope.protocol.Protocol; +import com.alibaba.dashscope.utils.Constants; +import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonObject; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.Test; + +public class TestDecisionModel { + + /** The success response from the decision model api document. */ + private static final String DOC_RESPONSE_BODY = + "{\n" + + " \"model\": \"decision-model-preview\",\n" + + " \"request_id\": \"7b986c65-b223-9341-b5f0-b988e27ecaac\",\n" + + " \"answers\": {\n" + + " \"department\": {\n" + + " \"type\": \"choice\",\n" + + " \"choice\": \"billing\",\n" + + " \"confidence\": 0.88,\n" + + " \"probabilities\": {\"billing\": 0.94, \"technical\": 0.06}\n" + + " },\n" + + " \"escalate\": {\"type\": \"noul\", \"noul\": 0.99},\n" + + " \"severity\": {\n" + + " \"type\": \"score\",\n" + + " \"score\": 2.25,\n" + + " \"confidence\": 0.91,\n" + + " \"legend\": {\"0\": \"轻微问题,不影响功能\", \"1\": \"部分功能受影响,但存在替代方案\",\n" + + " \"2\": \"核心功能不可用,没有替代方案\", \"3\": \"造成严重业务或安全影响\"},\n" + + " \"probabilities\": {\"0\": 0, \"1\": 0.01, \"2\": 0.73, \"3\": 0.26}\n" + + " }\n" + + " },\n" + + " \"usage\": {\"input_tokens\": 125},\n" + + " \"latency_ms\": 52.9\n" + + "}"; + + private static DecisionModelParam buildDocParam() { + Map state = new HashMap<>(); + state.put("ticket_id", "T-1001"); + state.put("content", "订单支付后超过 24 小时仍未到账,用户无法继续使用核心服务,要求立即处理。"); + + Map options = new LinkedHashMap<>(); + options.put("billing", "支付、退款和账单问题"); + options.put("technical", "产品故障和集成问题"); + + List levels = + Arrays.asList("轻微问题,不影响功能", "部分功能受影响,但存在替代方案", "核心功能不可用,没有替代方案", "造成严重业务或安全影响"); + + Map questions = new LinkedHashMap<>(); + questions.put("department", DecisionQuestion.choice("应该由哪个团队处理?", options)); + questions.put("escalate", DecisionQuestion.noul("是否需要立即通知值班人员?")); + questions.put("severity", DecisionQuestion.score("这个问题有多严重?", levels)); + + return DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(state) + .questions(questions) + .build(); + } + + @Test + public void testHttpBody() { + JsonObject body = buildDocParam().getHttpBody(); + assertEquals("decision-model-preview", body.get("model").getAsString()); + assertEquals("T-1001", body.getAsJsonObject("state").get("ticket_id").getAsString()); + + JsonObject questions = body.getAsJsonObject("questions"); + assertEquals(3, questions.size()); + + JsonObject department = questions.getAsJsonObject("department"); + assertEquals("choice", department.get("type").getAsString()); + assertEquals("应该由哪个团队处理?", department.get("instructions").getAsString()); + assertEquals("支付、退款和账单问题", department.getAsJsonObject("criteria").get("billing").getAsString()); + + JsonObject escalate = questions.getAsJsonObject("escalate"); + assertEquals("noul", escalate.get("type").getAsString()); + assertTrue(!escalate.has("criteria"), "noul question must not carry criteria"); + + JsonObject severity = questions.getAsJsonObject("severity"); + assertEquals("score", severity.get("type").getAsString()); + assertEquals(4, severity.getAsJsonArray("criteria").size()); + assertEquals("轻微问题,不影响功能", severity.getAsJsonArray("criteria").get(0).getAsString()); + } + + @Test + public void testValidate() throws InputRequiredException { + Map oneQuestion = new HashMap<>(); + oneQuestion.put("q1", DecisionQuestion.noul("是否升级?")); + + // model/state/questions are enforced at build time by lombok @NonNull. + assertThrows( + NullPointerException.class, + () -> DecisionModelParam.builder().state(new HashMap<>()).questions(oneQuestion).build()); + assertThrows( + NullPointerException.class, + () -> + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .questions(oneQuestion) + .build()); + assertThrows( + NullPointerException.class, + () -> + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(new HashMap<>()) + .build()); + + // empty questions passes the builder but fails validate(). + DecisionModelParam noQuestions = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(new HashMap<>()) + .questions(new HashMap<>()) + .build(); + assertThrows(InputRequiredException.class, noQuestions::validate); + + Map badType = new HashMap<>(); + badType.put("q1", DecisionQuestion.builder().type("bad").instructions("test").build()); + DecisionModelParam badTypeParam = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(new HashMap<>()) + .questions(badType) + .build(); + assertThrows(InputRequiredException.class, badTypeParam::validate); + + Map noCriteria = new HashMap<>(); + noCriteria.put("q1", DecisionQuestion.builder().type("choice").instructions("test").build()); + DecisionModelParam noCriteriaParam = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(new HashMap<>()) + .questions(noCriteria) + .build(); + assertThrows(InputRequiredException.class, noCriteriaParam::validate); + + buildDocParam().validate(); + } + + @Test + public void testFromDashScopeResult() throws Exception { + NetworkResponse response = + NetworkResponse.builder() + .headers(Collections.emptyMap()) + .message(DOC_RESPONSE_BODY) + .httpStatusCode(200) + .build(); + DashScopeResult dashScopeResult = + new DashScopeResult().fromResponse(Protocol.HTTP, response, true); + + DecisionModelResult result = DecisionModelResult.fromDashScopeResult(dashScopeResult); + assertEquals("decision-model-preview", result.getModel()); + assertEquals("7b986c65-b223-9341-b5f0-b988e27ecaac", result.getRequestId()); + assertEquals(125, result.getUsage().getInputTokens()); + assertEquals(52.9, result.getLatencyMs(), 1e-6); + assertEquals(3, result.getAnswers().size()); + + DecisionAnswer department = result.getAnswers().get("department"); + assertEquals("choice", department.getType()); + assertEquals("billing", department.getChoice()); + assertEquals(0.88, department.getConfidence(), 1e-6); + assertEquals(0.94, department.getProbabilities().get("billing"), 1e-6); + + DecisionAnswer escalate = result.getAnswers().get("escalate"); + assertEquals("noul", escalate.getType()); + assertEquals(0.99, escalate.getNoul(), 1e-6); + + DecisionAnswer severity = result.getAnswers().get("severity"); + assertEquals("score", severity.getType()); + assertEquals(2.25, severity.getScore(), 1e-6); + assertEquals("造成严重业务或安全影响", severity.getLegend().get("3")); + assertEquals(0.73, severity.getProbabilities().get("2"), 1e-6); + } + + @Test + public void testRealCall() throws Exception { + String apiKey = System.getenv(Constants.DASHSCOPE_API_KEY_ENV); + org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isEmpty()); + DecisionModel decisionModel = new DecisionModel(); + DecisionModelResult result = decisionModel.call(buildDocParam()); + assertNotNull(result.getRequestId()); + assertNotNull(result.getAnswers()); + System.out.println(JsonUtils.toJson(result)); + } + + /** End-to-end through the real okhttp stack against a local mock server. */ + @Test + public void testEndToEndWithMockServer() throws Exception { + MockWebServer server = new MockWebServer(); + server.start(); + try { + server.enqueue( + new MockResponse() + .setBody(DOC_RESPONSE_BODY) + .setHeader("content-type", "application/json; charset=utf-8")); + + DecisionModelParam param = buildDocParam(); + param.setApiKey("test-api-key"); + DecisionModel decisionModel = new DecisionModel(server.url("/compatible-mode/v1").toString()); + DecisionModelResult result = decisionModel.call(param); + + // response parsing + assertEquals("decision-model-preview", result.getModel()); + assertEquals("7b986c65-b223-9341-b5f0-b988e27ecaac", result.getRequestId()); + assertEquals(125, result.getUsage().getInputTokens()); + assertEquals(52.9, result.getLatencyMs(), 1e-6); + assertEquals("billing", result.getAnswers().get("department").getChoice()); + assertEquals(0.99, result.getAnswers().get("escalate").getNoul(), 1e-6); + assertEquals(2.25, result.getAnswers().get("severity").getScore(), 1e-6); + + // request on the wire + RecordedRequest request = server.takeRequest(10, TimeUnit.SECONDS); + assertNotNull(request, "no request reached the mock server"); + assertEquals("POST", request.getMethod()); + assertEquals("/compatible-mode/v1/systemone", request.getPath()); + assertEquals("Bearer test-api-key", request.getHeader("Authorization")); + assertEquals( + "java-sdk/" + Version.version + "/decision_model", + request.getHeader("x-dashscope-sdk-client")); + JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); + assertEquals("decision-model-preview", body.get("model").getAsString()); + assertEquals("T-1001", body.getAsJsonObject("state").get("ticket_id").getAsString()); + assertEquals(3, body.getAsJsonObject("questions").size()); + assertEquals( + "noul", + body.getAsJsonObject("questions").getAsJsonObject("escalate").get("type").getAsString()); + } finally { + server.close(); + } + } + + /** Non-2xx responses must surface the server error code and message. */ + @Test + public void testErrorResponseWithMockServer() throws Exception { + MockWebServer server = new MockWebServer(); + server.start(); + try { + server.enqueue( + new MockResponse() + .setResponseCode(400) + .setBody( + "{\"request_id\":\"req-err-1\",\"code\":\"InvalidParameter\",\"message\":\"questions is required\"}") + .setHeader("content-type", "application/json; charset=utf-8")); + + DecisionModelParam param = buildDocParam(); + param.setApiKey("test-api-key"); + DecisionModel decisionModel = new DecisionModel(server.url("/compatible-mode/v1").toString()); + + ApiException e = assertThrows(ApiException.class, () -> decisionModel.call(param)); + assertEquals(400, e.getStatus().getStatusCode()); + assertEquals("InvalidParameter", e.getStatus().getCode()); + assertEquals("questions is required", e.getStatus().getMessage()); + assertEquals("req-err-1", e.getStatus().getRequestId()); + } finally { + server.close(); + } + } +} From 6f8f9450cb0d39195dc6098c0b40ab1581c5c62f Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Sat, 10 Oct 2026 14:28:20 +0800 Subject: [PATCH 2/5] refactor(decision): Move decision model related classes from decision_model package to decision package --- README.md | 8 ++++---- README_zh.md | 8 ++++---- samples/DecisionModelUsage.java | 8 ++++---- .../{decision_model => decision}/DecisionAnswer.java | 2 +- .../{decision_model => decision}/DecisionModel.java | 4 ++-- .../DecisionModelParam.java | 2 +- .../DecisionModelResult.java | 2 +- .../DecisionModelUsage.java | 2 +- .../DecisionQuestion.java | 2 +- .../com/alibaba/dashscope/TestDecisionModel.java | 12 ++++++------ 10 files changed, 25 insertions(+), 25 deletions(-) rename src/main/java/com/alibaba/dashscope/{decision_model => decision}/DecisionAnswer.java (94%) rename src/main/java/com/alibaba/dashscope/{decision_model => decision}/DecisionModel.java (98%) rename src/main/java/com/alibaba/dashscope/{decision_model => decision}/DecisionModelParam.java (98%) rename src/main/java/com/alibaba/dashscope/{decision_model => decision}/DecisionModelResult.java (95%) rename src/main/java/com/alibaba/dashscope/{decision_model => decision}/DecisionModelUsage.java (85%) rename src/main/java/com/alibaba/dashscope/{decision_model => decision}/DecisionQuestion.java (97%) diff --git a/README.md b/README.md index 0752b64..8b187ef 100644 --- a/README.md +++ b/README.md @@ -179,10 +179,10 @@ The `call` method accepts a `GenerationParam`, and returns a `GenerationResult`, The decision model answers a batch of multiple-choice (choice), yes/no (noul) and ordered-scale (score) questions against a given business state, see the [Decision Model API document](https://platform.qianwenai.com/docs/api-reference/decision-model-api). ```java -import com.alibaba.dashscope.decision_model.DecisionModel; -import com.alibaba.dashscope.decision_model.DecisionModelParam; -import com.alibaba.dashscope.decision_model.DecisionModelResult; -import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.decision.DecisionModel; +import com.alibaba.dashscope.decision.DecisionModelParam; +import com.alibaba.dashscope.decision.DecisionModelResult; +import com.alibaba.dashscope.decision.DecisionQuestion; import com.alibaba.dashscope.exception.ApiException; import com.alibaba.dashscope.exception.InputRequiredException; import com.alibaba.dashscope.exception.NoApiKeyException; diff --git a/README_zh.md b/README_zh.md index b5c9c47..43a1741 100644 --- a/README_zh.md +++ b/README_zh.md @@ -179,10 +179,10 @@ public class Main { 决策模型(Decision Model)针对给定的业务状态,批量回答多选一(choice)、是否判断(noul)、有序量表(score)三类问题,详见[决策模型 API 文档](https://platform.qianwenai.com/docs/api-reference/decision-model-api)。 ```java -import com.alibaba.dashscope.decision_model.DecisionModel; -import com.alibaba.dashscope.decision_model.DecisionModelParam; -import com.alibaba.dashscope.decision_model.DecisionModelResult; -import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.decision.DecisionModel; +import com.alibaba.dashscope.decision.DecisionModelParam; +import com.alibaba.dashscope.decision.DecisionModelResult; +import com.alibaba.dashscope.decision.DecisionQuestion; import com.alibaba.dashscope.exception.ApiException; import com.alibaba.dashscope.exception.InputRequiredException; import com.alibaba.dashscope.exception.NoApiKeyException; diff --git a/samples/DecisionModelUsage.java b/samples/DecisionModelUsage.java index baa4237..2a289cd 100644 --- a/samples/DecisionModelUsage.java +++ b/samples/DecisionModelUsage.java @@ -1,9 +1,9 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -import com.alibaba.dashscope.decision_model.DecisionModel; -import com.alibaba.dashscope.decision_model.DecisionModelParam; -import com.alibaba.dashscope.decision_model.DecisionModelResult; -import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.decision.DecisionModel; +import com.alibaba.dashscope.decision.DecisionModelParam; +import com.alibaba.dashscope.decision.DecisionModelResult; +import com.alibaba.dashscope.decision.DecisionQuestion; import com.alibaba.dashscope.utils.JsonUtils; import java.util.Arrays; import java.util.HashMap; diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java similarity index 94% rename from src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java rename to src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java index 312e0d5..4c8cfcb 100644 --- a/src/main/java/com/alibaba/dashscope/decision_model/DecisionAnswer.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java @@ -1,5 +1,5 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -package com.alibaba.dashscope.decision_model; +package com.alibaba.dashscope.decision; import java.util.Map; import lombok.Data; diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java similarity index 98% rename from src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java rename to src/main/java/com/alibaba/dashscope/decision/DecisionModel.java index fa45b1a..838c435 100644 --- a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModel.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java @@ -1,5 +1,5 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -package com.alibaba.dashscope.decision_model; +package com.alibaba.dashscope.decision; import com.alibaba.dashscope.api.GeneralApi; import com.alibaba.dashscope.base.HalfDuplexParamBase; @@ -32,7 +32,7 @@ private GeneralServiceOption defaultServiceOption() { .httpMethod(HttpMethod.POST) .streamingMode(StreamingMode.NONE) .path("systemone") - .module("decision_model") + .module("decision") .baseHttpUrl( System.getenv().getOrDefault(Constants.DASHSCOPE_HTTP_BASE_URL_ENV, DEFAULT_BASE_URL)) .build(); diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java similarity index 98% rename from src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java rename to src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java index e3bd99b..f99563b 100644 --- a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelParam.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java @@ -1,5 +1,5 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -package com.alibaba.dashscope.decision_model; +package com.alibaba.dashscope.decision; import com.alibaba.dashscope.base.FlattenHalfDuplexParamBase; import com.alibaba.dashscope.exception.InputRequiredException; diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java similarity index 95% rename from src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java rename to src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java index 7823f73..1d3fc16 100644 --- a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelResult.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java @@ -1,5 +1,5 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -package com.alibaba.dashscope.decision_model; +package com.alibaba.dashscope.decision; import com.alibaba.dashscope.common.DashScopeResult; import com.alibaba.dashscope.utils.JsonUtils; diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelUsage.java similarity index 85% rename from src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java rename to src/main/java/com/alibaba/dashscope/decision/DecisionModelUsage.java index 622f74d..917881d 100644 --- a/src/main/java/com/alibaba/dashscope/decision_model/DecisionModelUsage.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelUsage.java @@ -1,5 +1,5 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -package com.alibaba.dashscope.decision_model; +package com.alibaba.dashscope.decision; import com.google.gson.annotations.SerializedName; import lombok.Data; diff --git a/src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java b/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java similarity index 97% rename from src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java rename to src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java index 9b4bdcb..452d41e 100644 --- a/src/main/java/com/alibaba/dashscope/decision_model/DecisionQuestion.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java @@ -1,5 +1,5 @@ // Copyright (c) Alibaba, Inc. and its affiliates. -package com.alibaba.dashscope.decision_model; +package com.alibaba.dashscope.decision; import com.alibaba.dashscope.utils.JsonUtils; import com.google.gson.JsonObject; diff --git a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java index 70a5434..53da4ed 100644 --- a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java +++ b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java @@ -8,11 +8,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.alibaba.dashscope.common.DashScopeResult; -import com.alibaba.dashscope.decision_model.DecisionAnswer; -import com.alibaba.dashscope.decision_model.DecisionModel; -import com.alibaba.dashscope.decision_model.DecisionModelParam; -import com.alibaba.dashscope.decision_model.DecisionModelResult; -import com.alibaba.dashscope.decision_model.DecisionQuestion; +import com.alibaba.dashscope.decision.DecisionAnswer; +import com.alibaba.dashscope.decision.DecisionModel; +import com.alibaba.dashscope.decision.DecisionModelParam; +import com.alibaba.dashscope.decision.DecisionModelResult; +import com.alibaba.dashscope.decision.DecisionQuestion; import com.alibaba.dashscope.exception.ApiException; import com.alibaba.dashscope.exception.InputRequiredException; import com.alibaba.dashscope.protocol.NetworkResponse; @@ -242,7 +242,7 @@ public void testEndToEndWithMockServer() throws Exception { assertEquals("/compatible-mode/v1/systemone", request.getPath()); assertEquals("Bearer test-api-key", request.getHeader("Authorization")); assertEquals( - "java-sdk/" + Version.version + "/decision_model", + "java-sdk/" + Version.version + "/decision", request.getHeader("x-dashscope-sdk-client")); JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); assertEquals("decision-model-preview", body.get("model").getAsString()); From 74793b14a07eb4163e7e86b6b5e091702ba86f99 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Sat, 10 Oct 2026 14:31:26 +0800 Subject: [PATCH 3/5] style(test): Fix code formatting in TestDecisionModel --- src/test/java/com/alibaba/dashscope/TestDecisionModel.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java index 53da4ed..2a2ab78 100644 --- a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java +++ b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java @@ -242,8 +242,7 @@ public void testEndToEndWithMockServer() throws Exception { assertEquals("/compatible-mode/v1/systemone", request.getPath()); assertEquals("Bearer test-api-key", request.getHeader("Authorization")); assertEquals( - "java-sdk/" + Version.version + "/decision", - request.getHeader("x-dashscope-sdk-client")); + "java-sdk/" + Version.version + "/decision", request.getHeader("x-dashscope-sdk-client")); JsonObject body = JsonUtils.parse(request.getBody().readUtf8()); assertEquals("decision-model-preview", body.get("model").getAsString()); assertEquals("T-1001", body.getAsJsonObject("state").get("ticket_id").getAsString()); From 9d5e8d4bdf8e67bfff52b5a2ecca339f7c3d4e25 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Wed, 14 Oct 2026 15:13:57 +0800 Subject: [PATCH 4/5] feat(decision): Improve decision model API implementation and enhance documentation - Add Lombok annotations support (@Builder, @NoArgsConstructor, @AllArgsConstructor) - Update DecisionAnswer class field descriptions and usage - Implement workspace-scoped base URL resolution logic - Add support for bare String and Array type state - Enhance parameter validation including options count and levels count limits - Support optional true/false description parameters for noul questions - Add null value checks and error handling mechanisms - Update API documentation links and region configuration constants - Add test cases covering various edge cases --- .../dashscope/decision/DecisionAnswer.java | 34 +++- .../dashscope/decision/DecisionModel.java | 136 +++++++++++--- .../decision/DecisionModelParam.java | 98 ++++++++-- .../decision/DecisionModelResult.java | 24 ++- .../dashscope/decision/DecisionQuestion.java | 58 ++++-- .../alibaba/dashscope/TestDecisionModel.java | 170 +++++++++++++++++- 6 files changed, 459 insertions(+), 61 deletions(-) diff --git a/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java index 4c8cfcb..3fdcf4c 100644 --- a/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java @@ -2,29 +2,49 @@ package com.alibaba.dashscope.decision; import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; - -/** The answer of a single decision question. */ +import lombok.NoArgsConstructor; + +/** + * The answer of a single decision question. Which fields are populated depends on {@link #type}: + * + *

    + *
  • {@code choice}: {@link #choice}, {@link #confidence}, {@link #probabilities} (keyed by + * option name). + *
  • {@code noul}: {@link #noul} only — {@link #confidence} and {@link #probabilities} are + * {@code null} per the documented example. + *
  • {@code score}: {@link #score}, {@link #confidence}, {@link #legend}, {@link + * #probabilities} (keyed by stringified level index {@code "0"}, {@code "1"}, ...). + *
+ */ @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class DecisionAnswer { /** The question type, one of choice/noul/score. */ private String type; - /** The selected option key, only for choice questions. */ + /** The selected option name, only for choice questions. */ private String choice; - /** The probability of "yes", only for noul questions. */ + /** P(yes) in [0, 1], only for noul questions. */ private Double noul; - /** The expected score over the scale levels, only for score questions. */ + /** + * Probability-weighted expectation of the level index, only for score questions. May fall between + * two integer levels (e.g. {@code 1.43}). + */ private Double score; /** The confidence of the answer, for choice and score questions. */ private Double confidence; - /** The probability of each option or scale level. */ + /** The probability of each option or level; sums to 1. */ private Map probabilities; - /** The description of each scale level, only for score questions. */ + /** Level index to level description, only for score questions. */ private Map legend; } diff --git a/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java index 838c435..5e02f67 100644 --- a/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java @@ -16,47 +16,143 @@ import com.alibaba.dashscope.utils.Constants; /** - * The decision model api, answers a batch of choice/noul/score questions against a business state. + * Unofficial Java binding for the Bailian Decision Model API ("TypeSafe System One"). * - *

Document: https://platform.qianwenai.com/docs/api-reference/decision-model-api + *

The upstream doc currently ships only cURL and Python ({@code typesafe-sdk}) examples; the + * Java surface here may evolve as the official spec matures. + * + *

Document: https://docs.bailian.console.aliyun.com/zh/model-studio/decision-model-api + * + *

The endpoint is workspace-scoped: {@code + * https://{WorkspaceId}.{region}.maas.aliyuncs.com/compatible-mode/v1/systemone}. A workspace id + * must be supplied either via the constructor or the {@value #ENV_WORKSPACE} environment variable. */ public final class DecisionModel { - private static final String DEFAULT_BASE_URL = "https://maas.qianwenaiapi.com/compatible-mode/v1"; + + /** Hostname template for the workspace-scoped decision model gateway. */ + public static final String BASE_URL_TEMPLATE = + "https://%s.%s.maas.aliyuncs.com/compatible-mode/v1"; + + /** Region used when neither the constructor nor {@value #ENV_REGION} provides one. */ + public static final String DEFAULT_REGION = "cn-beijing"; + + /** Environment variable holding the Bailian workspace id. */ + public static final String ENV_WORKSPACE = "DASHSCOPE_WORKSPACE"; + + /** Environment variable holding the region. */ + public static final String ENV_REGION = Constants.DASHSCOPE_API_REGION_ENV; + + /** + * Dedicated environment variable that fully overrides the base URL. Do not reuse {@link + * Constants#DASHSCOPE_HTTP_BASE_URL_ENV} here — that variable is bound to the {@code + * dashscope.aliyuncs.com/api/v1} shape used by the rest of the SDK. + */ + public static final String ENV_BASE_URL = "DASHSCOPE_DECISION_BASE_URL"; + + /** Documented Bailian regions that host the decision model gateway. */ + public static final class Regions { + public static final String CN_BEIJING = "cn-beijing"; + public static final String AP_SOUTHEAST_1 = "ap-southeast-1"; + + private Regions() {} + } private final GeneralApi api; private final GeneralServiceOption serviceOption; - private GeneralServiceOption defaultServiceOption() { + private static GeneralServiceOption defaultServiceOption() { return GeneralServiceOption.builder() .protocol(Protocol.HTTP) .httpMethod(HttpMethod.POST) .streamingMode(StreamingMode.NONE) .path("systemone") .module("decision") - .baseHttpUrl( - System.getenv().getOrDefault(Constants.DASHSCOPE_HTTP_BASE_URL_ENV, DEFAULT_BASE_URL)) .build(); } + /** + * Compose the workspace-scoped base URL. Honors {@link #ENV_BASE_URL} first; otherwise both + * arguments may be null/empty, in which case the corresponding environment variable (and finally + * {@link #DEFAULT_REGION}) is consulted. + * + * @throws IllegalArgumentException if neither a workspace id nor an explicit base URL override + * can be resolved. + */ + public static String resolveBaseUrl(String workspace, String region) { + String override = System.getenv(ENV_BASE_URL); + if (override != null && !override.isEmpty()) { + return override; + } + String ws = workspace; + if (ws == null || ws.isEmpty()) { + ws = System.getenv(ENV_WORKSPACE); + } + if (ws == null || ws.isEmpty()) { + throw new IllegalArgumentException( + "workspace is required. Pass it to the DecisionModel constructor, set the " + + ENV_WORKSPACE + + " or " + + ENV_BASE_URL + + " env var, or use DecisionModel.withBaseUrl(...) for a custom endpoint."); + } + String r = region; + if (r == null || r.isEmpty()) { + r = System.getenv(ENV_REGION); + } + if (r == null || r.isEmpty()) { + r = DEFAULT_REGION; + } + return String.format(BASE_URL_TEMPLATE, ws, r); + } + + /** Resolve workspace and region from the environment. */ public DecisionModel() { - serviceOption = defaultServiceOption(); - api = new GeneralApi<>(); + this(null, null, null); } - public DecisionModel(String baseUrl) { - serviceOption = defaultServiceOption(); - serviceOption.setBaseHttpUrl(baseUrl); - api = new GeneralApi<>(); + /** Use the given workspace with the region resolved from the environment. */ + public DecisionModel(String workspace) { + this(workspace, null, null); } - public DecisionModel(String baseUrl, ConnectionOptions connectionOptions) { + /** Use the given workspace and region. */ + public DecisionModel(String workspace, String region) { + this(workspace, region, null); + } + + public DecisionModel(String workspace, String region, ConnectionOptions connectionOptions) { serviceOption = defaultServiceOption(); - serviceOption.setBaseHttpUrl(baseUrl); - api = new GeneralApi<>(connectionOptions); + serviceOption.setBaseHttpUrl(resolveBaseUrl(workspace, region)); + api = connectionOptions == null ? new GeneralApi<>() : new GeneralApi<>(connectionOptions); + } + + /** Internal: build a client from an already-populated service option. */ + private DecisionModel(GeneralServiceOption option, ConnectionOptions connectionOptions) { + this.serviceOption = option; + this.api = connectionOptions == null ? new GeneralApi<>() : new GeneralApi<>(connectionOptions); + } + + /** + * Build a client that targets an explicit base URL, bypassing workspace/region resolution. + * Intended for tests, self-hosted gateways, and the Token Plan endpoint ({@code + * https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1}). + */ + public static DecisionModel withBaseUrl(String baseUrl) { + return withBaseUrl(baseUrl, null); + } + + public static DecisionModel withBaseUrl(String baseUrl, ConnectionOptions connectionOptions) { + if (baseUrl == null || baseUrl.isEmpty()) { + throw new IllegalArgumentException("baseUrl must not be null or empty"); + } + GeneralServiceOption option = defaultServiceOption(); + option.setBaseHttpUrl(baseUrl); + return new DecisionModel(option, connectionOptions); } /** - * Call the server to get the whole result. + * Call the server to get the whole result. The decision model does not stream — this is a single + * request/response round trip. * * @param param The input param of class `DecisionModelParam`. * @return The output structure of `DecisionModelResult`. @@ -67,13 +163,13 @@ public DecisionModel(String baseUrl, ConnectionOptions connectionOptions) { public DecisionModelResult call(DecisionModelParam param) throws ApiException, NoApiKeyException, InputRequiredException { param.validate(); - serviceOption.setIsSSE(false); - serviceOption.setStreamingMode(StreamingMode.NONE); return DecisionModelResult.fromDashScopeResult(api.call(param, serviceOption)); } /** - * Call the server to get the result in the callback function. + * Call the server and deliver the result through {@code callback}. The decision model returns a + * single response body, so the callback receives exactly one {@link + * ResultCallback#onEvent(Object)} before {@link ResultCallback#onComplete()}. * * @param param The input param of class `DecisionModelParam`. * @param callback The callback to receive the result of `DecisionModelResult`. @@ -84,8 +180,6 @@ public DecisionModelResult call(DecisionModelParam param) public void call(DecisionModelParam param, ResultCallback callback) throws ApiException, NoApiKeyException, InputRequiredException { param.validate(); - serviceOption.setIsSSE(false); - serviceOption.setStreamingMode(StreamingMode.NONE); api.call( param, serviceOption, diff --git a/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java index f99563b..be652ff 100644 --- a/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java @@ -6,12 +6,26 @@ import com.alibaba.dashscope.utils.JsonUtils; import com.google.gson.JsonObject; import io.reactivex.annotations.NonNull; +import java.util.List; import java.util.Map; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.experimental.SuperBuilder; -/** The input param of the decision model api. */ +/** + * The input param of the decision model api. + * + *

Limits documented at + * https://docs.bailian.console.aliyun.com/zh/model-studio/decision-model-api : + * + *

    + *
  • {@code state} accepts a String, an Object, or an Array; the total request must fit within + * 65536 context tokens. + *
  • {@code questions} count is not hard-capped; the doc recommends ≤ 16. + *
  • {@code choice} criteria: 1–255 options. + *
  • {@code score} criteria: 2–10 levels (recommended 3–7). + *
+ */ @EqualsAndHashCode(callSuper = true) @Data @SuperBuilder @@ -20,22 +34,36 @@ public class DecisionModelParam extends FlattenHalfDuplexParamBase { /** Available decision models. */ public static class Models { public static final String DECISION_MODEL_PREVIEW = "decision-model-preview"; + + private Models() {} } + /** Documented upper bound on {@code choice} options. */ + public static final int MAX_CHOICE_OPTIONS = 255; + + /** Documented bounds on {@code score} levels. */ + public static final int MIN_SCORE_LEVELS = 2; + + public static final int MAX_SCORE_LEVELS = 10; + /** The model name, currently only {@link Models#DECISION_MODEL_PREVIEW}. */ @NonNull private String model; - /** The business state, an arbitrary json object, e.g. {"ticket_id": "T-1001", ...}. */ - @NonNull private Map state; + /** + * The business state: a String, a {@code Map}, or a {@code List}. The doc's + * noul example passes a bare String; the ticket-routing example passes an object. Kept as {@link + * Object} so all three shapes serialize verbatim. + */ + @NonNull private Object state; - /** The questions to answer, key is the question id. */ + /** The questions to answer, key is the caller-defined question id. */ @NonNull private Map questions; @Override public JsonObject getHttpBody() { JsonObject requestObject = new JsonObject(); requestObject.addProperty("model", model); - requestObject.add("state", JsonUtils.toJsonObject(state)); + requestObject.add("state", JsonUtils.toJsonElement(state)); JsonObject questionsObject = new JsonObject(); for (Map.Entry entry : questions.entrySet()) { questionsObject.add(entry.getKey(), entry.getValue().toJson()); @@ -57,27 +85,63 @@ public void validate() throws InputRequiredException { throw new InputRequiredException("The questions must not be null or empty"); } for (Map.Entry entry : questions.entrySet()) { - DecisionQuestion question = entry.getValue(); - if (question == null) { + validateQuestion(entry.getKey(), entry.getValue()); + } + } + + private static void validateQuestion(String id, DecisionQuestion question) + throws InputRequiredException { + if (question == null) { + throw new InputRequiredException(String.format("The question [%s] must not be null", id)); + } + String type = question.getType(); + if (!DecisionQuestion.Types.CHOICE.equals(type) + && !DecisionQuestion.Types.NOUL.equals(type) + && !DecisionQuestion.Types.SCORE.equals(type)) { + throw new InputRequiredException( + String.format("The type of question [%s] must be one of choice/noul/score", id)); + } + // `instructions` is documented as optional; only reject when the caller went out of their way + // to set an empty string. + if (question.getInstructions() != null && question.getInstructions().isEmpty()) { + throw new InputRequiredException( + String.format("The instructions of question [%s] must not be empty", id)); + } + Object criteria = question.getCriteria(); + if (DecisionQuestion.Types.CHOICE.equals(type)) { + if (!(criteria instanceof Map)) { throw new InputRequiredException( - String.format("The question [%s] must not be null", entry.getKey())); + String.format( + "The criteria of choice question [%s] must be a Map", id)); } - String type = question.getType(); - if (!DecisionQuestion.Types.CHOICE.equals(type) - && !DecisionQuestion.Types.NOUL.equals(type) - && !DecisionQuestion.Types.SCORE.equals(type)) { + int size = ((Map) criteria).size(); + if (size < 1 || size > MAX_CHOICE_OPTIONS) { throw new InputRequiredException( String.format( - "The type of question [%s] must be one of choice/noul/score", entry.getKey())); + "The criteria of choice question [%s] must contain 1..%d options, got %d", + id, MAX_CHOICE_OPTIONS, size)); } - if (question.getInstructions() == null || question.getInstructions().isEmpty()) { + } else if (DecisionQuestion.Types.SCORE.equals(type)) { + if (!(criteria instanceof List)) { throw new InputRequiredException( - String.format("The instructions of question [%s] must be set", entry.getKey())); + String.format( + "The criteria of score question [%s] must be a List of level descriptions", + id)); } - if (!DecisionQuestion.Types.NOUL.equals(type) && question.getCriteria() == null) { + int size = ((List) criteria).size(); + if (size < MIN_SCORE_LEVELS || size > MAX_SCORE_LEVELS) { throw new InputRequiredException( - String.format("The criteria of question [%s] must be set", entry.getKey())); + String.format( + "The criteria of score question [%s] must contain %d..%d levels, got %d", + id, MIN_SCORE_LEVELS, MAX_SCORE_LEVELS, size)); } + } else if (DecisionQuestion.Types.NOUL.equals(type) + && criteria != null + && !(criteria instanceof Map)) { + throw new InputRequiredException( + String.format( + "The criteria of noul question [%s] must be null or a Map with 'true'/'false' keys", + id)); } } } diff --git a/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java index 1d3fc16..c696b54 100644 --- a/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java @@ -3,6 +3,7 @@ import com.alibaba.dashscope.common.DashScopeResult; import com.alibaba.dashscope.utils.JsonUtils; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.annotations.SerializedName; import java.util.Map; @@ -16,9 +17,10 @@ public class DecisionModelResult { @SerializedName("request_id") private String requestId; - /** The answers, key is the question id. */ + /** The answers, key is the caller-defined question id from the request. */ private Map answers; + /** Metering info. The doc currently defines only {@code input_tokens}. */ private DecisionModelUsage usage; /** The server side latency in milliseconds. */ @@ -26,6 +28,24 @@ public class DecisionModelResult { private Double latencyMs; public static DecisionModelResult fromDashScopeResult(DashScopeResult result) { - return JsonUtils.fromJsonObject((JsonObject) result.getOutput(), DecisionModelResult.class); + if (result == null) { + throw new IllegalArgumentException("DashScopeResult must not be null"); + } + Object output = result.getOutput(); + if (output == null) { + throw new IllegalStateException( + "Decision model response has no output payload; request_id=" + + (result.getRequestId() == null ? "" : result.getRequestId())); + } + if (!(output instanceof JsonObject)) { + throw new IllegalStateException( + "Decision model response output is not a JSON object: " + output.getClass().getName()); + } + JsonElement element = (JsonObject) output; + DecisionModelResult parsed = JsonUtils.fromJsonObject(element, DecisionModelResult.class); + if (parsed != null && parsed.getRequestId() == null && result.getRequestId() != null) { + parsed.setRequestId(result.getRequestId()); + } + return parsed; } } diff --git a/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java b/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java index 452d41e..bb3101d 100644 --- a/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java @@ -3,38 +3,50 @@ import com.alibaba.dashscope.utils.JsonUtils; import com.google.gson.JsonObject; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import lombok.Builder; import lombok.Data; -import lombok.experimental.SuperBuilder; /** * A single question for the decision model. Use the static factory methods {@link #choice}, {@link * #noul} and {@link #score} to create instances. */ @Data -@SuperBuilder +@Builder public class DecisionQuestion { /** Question types. */ - public static class Types { - /** Multiple choice, criteria is a map of option key to option description. */ + public static final class Types { + /** Multiple choice, criteria is a map of option name to option description. */ public static final String CHOICE = "choice"; - /** Yes/no judgement, no criteria needed. */ + + /** Yes/no judgement, criteria is optional {@code {"true": ..., "false": ...}} descriptions. */ public static final String NOUL = "noul"; - /** Ordered scale, criteria is a list of level descriptions. */ + + /** Ordered scale, criteria is a list of level descriptions ordered low to high. */ public static final String SCORE = "score"; + + private Types() {} } /** The question type, one of {@link Types}. */ private String type; - /** The question description. */ + /** The question description or evaluation criteria. Documented as optional but usually set. */ private String instructions; /** - * The question criteria. For {@link Types#CHOICE} it is a {@code Map} of option - * key to option description. For {@link Types#SCORE} it is a {@code List} of level - * descriptions. Not used for {@link Types#NOUL}. + * The question criteria. + * + *
    + *
  • {@link Types#CHOICE}: {@code Map} of option name to option description + * (1–255 entries). + *
  • {@link Types#SCORE}: {@code List} of level descriptions ordered low to high + * (2–10 levels). + *
  • {@link Types#NOUL}: optional {@code Map} with {@code "true"} / {@code + * "false"} keys. + *
*/ private Object criteria; @@ -50,6 +62,28 @@ public static DecisionQuestion noul(String instructions) { return DecisionQuestion.builder().type(Types.NOUL).instructions(instructions).build(); } + /** + * Noul question with explicit yes/no descriptions. Both arguments are optional; pass {@code null} + * to omit either. + */ + public static DecisionQuestion noul(String instructions, String trueDesc, String falseDesc) { + if (trueDesc == null && falseDesc == null) { + return noul(instructions); + } + Map criteria = new LinkedHashMap<>(); + if (trueDesc != null) { + criteria.put("true", trueDesc); + } + if (falseDesc != null) { + criteria.put("false", falseDesc); + } + return DecisionQuestion.builder() + .type(Types.NOUL) + .instructions(instructions) + .criteria(criteria) + .build(); + } + public static DecisionQuestion score(String instructions, List levels) { return DecisionQuestion.builder() .type(Types.SCORE) @@ -61,7 +95,9 @@ public static DecisionQuestion score(String instructions, List levels) { JsonObject toJson() { JsonObject jsonObject = new JsonObject(); jsonObject.addProperty("type", type); - jsonObject.addProperty("instructions", instructions); + if (instructions != null) { + jsonObject.addProperty("instructions", instructions); + } if (criteria != null) { jsonObject.add("criteria", JsonUtils.toJsonElement(criteria)); } diff --git a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java index 2a2ab78..fc7a90c 100644 --- a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java +++ b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -20,6 +21,7 @@ import com.alibaba.dashscope.utils.Constants; import com.alibaba.dashscope.utils.JsonUtils; import com.google.gson.JsonObject; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -108,6 +110,57 @@ public void testHttpBody() { assertEquals("轻微问题,不影响功能", severity.getAsJsonArray("criteria").get(0).getAsString()); } + /** Doc example B: state is a bare String, not an object. */ + @Test + public void testHttpBodyWithStringState() { + Map questions = new LinkedHashMap<>(); + questions.put("refund", DecisionQuestion.noul("是否在索要退款?")); + DecisionModelParam param = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("这段评论是否在索要退款?") + .questions(questions) + .build(); + JsonObject body = param.getHttpBody(); + assertTrue(body.get("state").isJsonPrimitive()); + assertEquals("这段评论是否在索要退款?", body.get("state").getAsString()); + } + + /** Doc allows an Array state as well. */ + @Test + public void testHttpBodyWithListState() { + Map questions = new LinkedHashMap<>(); + questions.put("q", DecisionQuestion.noul("ok?")); + DecisionModelParam param = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state(Arrays.asList("turn-1", "turn-2")) + .questions(questions) + .build(); + JsonObject body = param.getHttpBody(); + assertTrue(body.get("state").isJsonArray()); + assertEquals(2, body.getAsJsonArray("state").size()); + } + + /** Noul questions accept optional {"true": ..., "false": ...} criteria. */ + @Test + public void testNoulWithTrueFalseCriteria() { + DecisionQuestion q = DecisionQuestion.noul("是否在索要退款?", "用户在要求退款", "用户没有要求退款"); + Map questions = new LinkedHashMap<>(); + questions.put("refund", q); + DecisionModelParam param = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("...") + .questions(questions) + .build(); + JsonObject body = param.getHttpBody(); + JsonObject refund = body.getAsJsonObject("questions").getAsJsonObject("refund"); + assertEquals("noul", refund.get("type").getAsString()); + assertEquals("用户在要求退款", refund.getAsJsonObject("criteria").get("true").getAsString()); + assertEquals("用户没有要求退款", refund.getAsJsonObject("criteria").get("false").getAsString()); + } + @Test public void testValidate() throws InputRequiredException { Map oneQuestion = new HashMap<>(); @@ -164,6 +217,80 @@ public void testValidate() throws InputRequiredException { buildDocParam().validate(); } + @Test + public void testValidateChoiceOptionCount() { + Map tooMany = new LinkedHashMap<>(); + for (int i = 0; i <= DecisionModelParam.MAX_CHOICE_OPTIONS; i++) { + tooMany.put("opt" + i, "desc" + i); + } + Map questions = new HashMap<>(); + questions.put("q", DecisionQuestion.choice("pick", tooMany)); + DecisionModelParam param = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("x") + .questions(questions) + .build(); + assertThrows(InputRequiredException.class, param::validate); + } + + @Test + public void testValidateScoreLevelCount() { + Map tooFew = new HashMap<>(); + tooFew.put("q", DecisionQuestion.score("rate", Collections.singletonList("only-one"))); + DecisionModelParam tooFewParam = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("x") + .questions(tooFew) + .build(); + assertThrows(InputRequiredException.class, tooFewParam::validate); + + List tooMany = new ArrayList<>(); + for (int i = 0; i <= DecisionModelParam.MAX_SCORE_LEVELS; i++) { + tooMany.add("level" + i); + } + Map manyQs = new HashMap<>(); + manyQs.put("q", DecisionQuestion.score("rate", tooMany)); + DecisionModelParam tooManyParam = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("x") + .questions(manyQs) + .build(); + assertThrows(InputRequiredException.class, tooManyParam::validate); + } + + @Test + public void testValidateCriteriaShapeMismatch() { + // score question given a Map instead of a List + Map qs = new HashMap<>(); + Map wrongShape = new LinkedHashMap<>(); + wrongShape.put("0", "low"); + wrongShape.put("1", "high"); + qs.put("q", DecisionQuestion.builder().type("score").instructions("rate").criteria(wrongShape).build()); + DecisionModelParam param = + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("x") + .questions(qs) + .build(); + assertThrows(InputRequiredException.class, param::validate); + } + + @Test + public void testValidateInstructionsOptional() throws InputRequiredException { + // Doc says instructions is optional; a null instructions must still validate. + Map qs = new HashMap<>(); + qs.put("q", DecisionQuestion.noul(null)); + DecisionModelParam.builder() + .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) + .state("x") + .questions(qs) + .build() + .validate(); + } + @Test public void testFromDashScopeResult() throws Exception { NetworkResponse response = @@ -191,6 +318,9 @@ public void testFromDashScopeResult() throws Exception { DecisionAnswer escalate = result.getAnswers().get("escalate"); assertEquals("noul", escalate.getType()); assertEquals(0.99, escalate.getNoul(), 1e-6); + // doc example omits confidence/probabilities for noul + assertNull(escalate.getConfidence()); + assertNull(escalate.getProbabilities()); DecisionAnswer severity = result.getAnswers().get("severity"); assertEquals("score", severity.getType()); @@ -199,11 +329,43 @@ public void testFromDashScopeResult() throws Exception { assertEquals(0.73, severity.getProbabilities().get("2"), 1e-6); } + @Test + public void testFromDashScopeResultRejectsNull() { + assertThrows(IllegalArgumentException.class, () -> DecisionModelResult.fromDashScopeResult(null)); + } + + @Test + public void testResolveBaseUrl() { + assertEquals( + "https://ws-abc.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + DecisionModel.resolveBaseUrl("ws-abc", "cn-beijing")); + assertEquals( + "https://ws-abc.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1", + DecisionModel.resolveBaseUrl("ws-abc", DecisionModel.Regions.AP_SOUTHEAST_1)); + } + + @Test + public void testResolveBaseUrlRequiresWorkspace() { + // Only reliable when the environment does not already provide an override. + org.junit.jupiter.api.Assumptions.assumeTrue(System.getenv(DecisionModel.ENV_WORKSPACE) == null); + org.junit.jupiter.api.Assumptions.assumeTrue(System.getenv(DecisionModel.ENV_BASE_URL) == null); + assertThrows(IllegalArgumentException.class, () -> DecisionModel.resolveBaseUrl(null, null)); + assertThrows(IllegalArgumentException.class, () -> DecisionModel.resolveBaseUrl("", "")); + } + + @Test + public void testWithBaseUrlRejectsEmpty() { + assertThrows(IllegalArgumentException.class, () -> DecisionModel.withBaseUrl(null)); + assertThrows(IllegalArgumentException.class, () -> DecisionModel.withBaseUrl("")); + } + @Test public void testRealCall() throws Exception { String apiKey = System.getenv(Constants.DASHSCOPE_API_KEY_ENV); + String workspace = System.getenv(DecisionModel.ENV_WORKSPACE); org.junit.jupiter.api.Assumptions.assumeTrue(apiKey != null && !apiKey.isEmpty()); - DecisionModel decisionModel = new DecisionModel(); + org.junit.jupiter.api.Assumptions.assumeTrue(workspace != null && !workspace.isEmpty()); + DecisionModel decisionModel = new DecisionModel(workspace); DecisionModelResult result = decisionModel.call(buildDocParam()); assertNotNull(result.getRequestId()); assertNotNull(result.getAnswers()); @@ -223,7 +385,8 @@ public void testEndToEndWithMockServer() throws Exception { DecisionModelParam param = buildDocParam(); param.setApiKey("test-api-key"); - DecisionModel decisionModel = new DecisionModel(server.url("/compatible-mode/v1").toString()); + DecisionModel decisionModel = + DecisionModel.withBaseUrl(server.url("/compatible-mode/v1").toString()); DecisionModelResult result = decisionModel.call(param); // response parsing @@ -270,7 +433,8 @@ public void testErrorResponseWithMockServer() throws Exception { DecisionModelParam param = buildDocParam(); param.setApiKey("test-api-key"); - DecisionModel decisionModel = new DecisionModel(server.url("/compatible-mode/v1").toString()); + DecisionModel decisionModel = + DecisionModel.withBaseUrl(server.url("/compatible-mode/v1").toString()); ApiException e = assertThrows(ApiException.class, () -> decisionModel.call(param)); assertEquals(400, e.getStatus().getStatusCode()); From 3dfa1563476fa639828ded644eb36e87a6dac636 Mon Sep 17 00:00:00 2001 From: luk384090-cloud Date: Wed, 14 Oct 2026 15:16:37 +0800 Subject: [PATCH 5/5] style(decision): Fix code formatting in DecisionAnswer and TestDecisionModel --- .../com/alibaba/dashscope/decision/DecisionAnswer.java | 4 ++-- .../java/com/alibaba/dashscope/TestDecisionModel.java | 10 +++++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java index 3fdcf4c..b48daf1 100644 --- a/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java @@ -15,8 +15,8 @@ * option name). *
  • {@code noul}: {@link #noul} only — {@link #confidence} and {@link #probabilities} are * {@code null} per the documented example. - *
  • {@code score}: {@link #score}, {@link #confidence}, {@link #legend}, {@link - * #probabilities} (keyed by stringified level index {@code "0"}, {@code "1"}, ...). + *
  • {@code score}: {@link #score}, {@link #confidence}, {@link #legend}, {@link #probabilities} + * (keyed by stringified level index {@code "0"}, {@code "1"}, ...). * */ @Data diff --git a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java index fc7a90c..8c5946b 100644 --- a/src/test/java/com/alibaba/dashscope/TestDecisionModel.java +++ b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java @@ -268,7 +268,9 @@ public void testValidateCriteriaShapeMismatch() { Map wrongShape = new LinkedHashMap<>(); wrongShape.put("0", "low"); wrongShape.put("1", "high"); - qs.put("q", DecisionQuestion.builder().type("score").instructions("rate").criteria(wrongShape).build()); + qs.put( + "q", + DecisionQuestion.builder().type("score").instructions("rate").criteria(wrongShape).build()); DecisionModelParam param = DecisionModelParam.builder() .model(DecisionModelParam.Models.DECISION_MODEL_PREVIEW) @@ -331,7 +333,8 @@ public void testFromDashScopeResult() throws Exception { @Test public void testFromDashScopeResultRejectsNull() { - assertThrows(IllegalArgumentException.class, () -> DecisionModelResult.fromDashScopeResult(null)); + assertThrows( + IllegalArgumentException.class, () -> DecisionModelResult.fromDashScopeResult(null)); } @Test @@ -347,7 +350,8 @@ public void testResolveBaseUrl() { @Test public void testResolveBaseUrlRequiresWorkspace() { // Only reliable when the environment does not already provide an override. - org.junit.jupiter.api.Assumptions.assumeTrue(System.getenv(DecisionModel.ENV_WORKSPACE) == null); + org.junit.jupiter.api.Assumptions.assumeTrue( + System.getenv(DecisionModel.ENV_WORKSPACE) == null); org.junit.jupiter.api.Assumptions.assumeTrue(System.getenv(DecisionModel.ENV_BASE_URL) == null); assertThrows(IllegalArgumentException.class, () -> DecisionModel.resolveBaseUrl(null, null)); assertThrows(IllegalArgumentException.class, () -> DecisionModel.resolveBaseUrl("", ""));