diff --git a/README.md b/README.md index bee11a6..8b187ef 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.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; +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..43a1741 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.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; +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..2a289cd --- /dev/null +++ b/samples/DecisionModelUsage.java @@ -0,0 +1,47 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. + +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; +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/DecisionAnswer.java b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java new file mode 100644 index 0000000..b48daf1 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionAnswer.java @@ -0,0 +1,50 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision; + +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +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 name, only for choice questions. */ + private String choice; + + /** P(yes) in [0, 1], only for noul questions. */ + private Double noul; + + /** + * 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 level; sums to 1. */ + private Map probabilities; + + /** 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 new file mode 100644 index 0000000..5e02f67 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModel.java @@ -0,0 +1,203 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision; + +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; + +/** + * Unofficial Java binding for the Bailian Decision Model API ("TypeSafe System One"). + * + *

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 { + + /** 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 static GeneralServiceOption defaultServiceOption() { + return GeneralServiceOption.builder() + .protocol(Protocol.HTTP) + .httpMethod(HttpMethod.POST) + .streamingMode(StreamingMode.NONE) + .path("systemone") + .module("decision") + .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() { + this(null, null, null); + } + + /** Use the given workspace with the region resolved from the environment. */ + public DecisionModel(String workspace) { + this(workspace, null, null); + } + + /** 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(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. 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`. + * @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(); + return DecisionModelResult.fromDashScopeResult(api.call(param, serviceOption)); + } + + /** + * 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`. + * @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(); + 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/DecisionModelParam.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java new file mode 100644 index 0000000..be652ff --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelParam.java @@ -0,0 +1,147 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision; + +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.List; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.SuperBuilder; + +/** + * 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 +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: 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 caller-defined question id. */ + @NonNull private Map questions; + + @Override + public JsonObject getHttpBody() { + JsonObject requestObject = new JsonObject(); + requestObject.addProperty("model", model); + requestObject.add("state", JsonUtils.toJsonElement(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()) { + 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 criteria of choice question [%s] must be a Map", id)); + } + int size = ((Map) criteria).size(); + if (size < 1 || size > MAX_CHOICE_OPTIONS) { + throw new InputRequiredException( + String.format( + "The criteria of choice question [%s] must contain 1..%d options, got %d", + id, MAX_CHOICE_OPTIONS, size)); + } + } else if (DecisionQuestion.Types.SCORE.equals(type)) { + if (!(criteria instanceof List)) { + throw new InputRequiredException( + String.format( + "The criteria of score question [%s] must be a List of level descriptions", + id)); + } + int size = ((List) criteria).size(); + if (size < MIN_SCORE_LEVELS || size > MAX_SCORE_LEVELS) { + throw new InputRequiredException( + 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 new file mode 100644 index 0000000..c696b54 --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelResult.java @@ -0,0 +1,51 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision; + +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; +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 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. */ + @SerializedName("latency_ms") + private Double latencyMs; + + public static DecisionModelResult fromDashScopeResult(DashScopeResult result) { + 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/DecisionModelUsage.java b/src/main/java/com/alibaba/dashscope/decision/DecisionModelUsage.java new file mode 100644 index 0000000..917881d --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionModelUsage.java @@ -0,0 +1,12 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision; + +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/DecisionQuestion.java b/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java new file mode 100644 index 0000000..bb3101d --- /dev/null +++ b/src/main/java/com/alibaba/dashscope/decision/DecisionQuestion.java @@ -0,0 +1,106 @@ +// Copyright (c) Alibaba, Inc. and its affiliates. +package com.alibaba.dashscope.decision; + +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; + +/** + * A single question for the decision model. Use the static factory methods {@link #choice}, {@link + * #noul} and {@link #score} to create instances. + */ +@Data +@Builder +public class DecisionQuestion { + /** Question types. */ + 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, criteria is optional {@code {"true": ..., "false": ...}} descriptions. */ + public static final String NOUL = "noul"; + + /** 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 or evaluation criteria. Documented as optional but usually set. */ + private String instructions; + + /** + * 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; + + 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(); + } + + /** + * 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) + .instructions(instructions) + .criteria(levels) + .build(); + } + + JsonObject toJson() { + JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty("type", type); + if (instructions != null) { + 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..8c5946b --- /dev/null +++ b/src/test/java/com/alibaba/dashscope/TestDecisionModel.java @@ -0,0 +1,452 @@ +// 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.assertNull; +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.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; +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.ArrayList; +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()); + } + + /** 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<>(); + 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 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 = + 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); + // doc example omits confidence/probabilities for noul + assertNull(escalate.getConfidence()); + assertNull(escalate.getProbabilities()); + + 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 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()); + 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()); + 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 = + DecisionModel.withBaseUrl(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", 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 = + DecisionModel.withBaseUrl(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(); + } + } +}